Compare commits

...

7 Commits

6 changed files with 353 additions and 108 deletions

View File

@ -4,6 +4,8 @@ using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Metadata;
using static McRule.Tests.TestPolicies;
namespace McRule.Tests {
public class Filtering {
@ -48,102 +50,6 @@ namespace McRule.Tests {
}
}
#region testPolicies
ExpressionPolicy everyKindInclusive = new ExpressionPolicy {
Name = "Any kind including null",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "kind", "*").ToFilterRule(),
}
};
ExpressionPolicy matchNullLiteral = new ExpressionPolicy {
Name = "Any kind null",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "kind", "{{NULL}}").ToFilterRule(),
}
};
ExpressionPolicy matchNullByString = new ExpressionPolicy {
Name = "Don't think this should work",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "kind", "null").ToFilterRule(),
}
};
ExpressionPolicy notSean = new ExpressionPolicy {
Name = "Not named Sean",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "name", "!Sean").ToFilterRule(),
}
};
ExpressionPolicy eans = new ExpressionPolicy {
Name = "eans",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "name", "*ean").ToFilterRule(),
("People", "name", "~*EAN").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
ExpressionPolicy youngens = new ExpressionPolicy {
Name = "Young folk",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "number", ">=17").ToFilterRule(),
("People", "number", "<30").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
ExpressionPolicy vikings = new ExpressionPolicy {
Name = "Vikings",
Rules = new List<ExpressionRule>
{
("People", "kind", "~viking").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
ExpressionPolicy muggles = new ExpressionPolicy {
Name = "Non-magic folk",
Rules = new List<ExpressionRule>
{
("People", "tags", "muggle").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
ExpressionPolicy notQuiteDead = new ExpressionPolicy {
Rules = new List<ExpressionRule>
{
("People", "stillWithUs", "true").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
ExpressionPolicy deadOrViking = new ExpressionPolicy {
Rules = new List<ExpressionRule>
{
("People", "stillWithUs", "false").ToFilterRule(),
("People", "kind", "Viking").ToFilterRule(),
},
RuleOperator = RuleOperator.Or
};
#endregion testPolicies
[SetUp]
public void Setup() { }

View File

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;
using static McRule.Tests.TestPolicies;
namespace McRule.Tests {
public class IDictionarySelector {
public class SomeContext {
public string Name { get; set; }
public bool Authorized { get; set; } = false;
public ContextStringDictionary Context { get; set; }
}
public class ContextStringDictionary : Dictionary<string, string> { }
public List<SomeContext> SomeContexts = new List<SomeContext>() {
new SomeContext {
Name = "Me",
Context = new ContextStringDictionary() {
{ "GivenName", "Sean"},
{ "Surname", "McArdle" },
{ "Department", "IT" },
{ "Team", "Cloud" },
}
},
new SomeContext {
Name = "Dog",
Context = new ContextStringDictionary() {
{ "GivenName", "Navi"},
{ "Surname", "McArdle" },
{ "Department", "Security" },
{ "Team", "Pets" },
}
},
new SomeContext {
Name = "Son",
Context = new ContextStringDictionary() {
{ "GivenName", "Thing-1"},
{ "Surname", "McArdle" },
{ "Department", "IT" },
{ "Team", "Children" },
}
},
new SomeContext() {
Name = "Son",
Context = new ContextStringDictionary() {
{ "GivenName", "Thing-2"},
{ "Surname", "McArdle" },
{ "Team", "Children" },
}
},
new SomeContext() {
Name = "Nerd Son",
Context = new ContextStringDictionary() {
{ "GivenName", "Thing-2"},
{ "Surname", "McArdle" },
{ "Team", "Children" },
{ "Department", "it" },
}
}
};
[SetUp] public void SetUp() {
}
[Test]
public void CanSelectDictionaryValuesByKey() {
var lambda = itPeople.GetPredicateExpression<ContextStringDictionary>();
var filter = lambda.Compile();
var localContext = SomeContexts;
var filteredContexts = localContext.Select(x => x.Context)
.Where(x => x.ContainsKey("Department")) // Skip entry with a missing key
.Where(filter);
Assert.NotNull(filteredContexts);
Assert.AreEqual(filteredContexts.Count(), 2);
}
[Test]
public void CanSelectDictionaryValuesByKeyCaseInsensitive() {
var lambda = itPeopleCaseless.GetPredicateExpression<ContextStringDictionary>();
var filter = lambda.Compile();
var localContext = SomeContexts;
var filteredContexts = localContext.Select(x => x.Context)
.Where(x => x.ContainsKey("Department")) // Skip entry with a missing key
.Where(filter);
Assert.NotNull(filteredContexts);
Assert.AreEqual(filteredContexts.Count(), 3);
}
[Test]
public void CanSelectDictionaryValueWithContainsKeyCheck() {
var lambda = itPeople.GetPredicateExpression<ContextStringDictionary>();
var filter = lambda.Compile();
var localContext = SomeContexts;
var filteredContexts = localContext.Select(x => x.Context)
.Where(filter);
Assert.NotNull(filteredContexts);
int filteredCount = filteredContexts.Count();
Assert.AreEqual(filteredCount, 2);
}
}
}

View File

@ -0,0 +1,132 @@
using McRule;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace McRule.Tests {
public static class TestPolicies {
#region peopleTests
public static ExpressionPolicy everyKindInclusive = new ExpressionPolicy {
Name = "Any kind including null",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "kind", "*").ToFilterRule(),
}
};
public static ExpressionPolicy matchNullLiteral = new ExpressionPolicy {
Name = "Any kind null",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "kind", "{{NULL}}").ToFilterRule(),
}
};
public static ExpressionPolicy matchNullByString = new ExpressionPolicy {
Name = "Don't think this should work",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "kind", "null").ToFilterRule(),
}
};
public static ExpressionPolicy notSean = new ExpressionPolicy {
Name = "Not named Sean",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "name", "!Sean").ToFilterRule(),
}
};
public static ExpressionPolicy eans = new ExpressionPolicy {
Name = "eans",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "name", "*ean").ToFilterRule(),
("People", "name", "~*EAN").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
public static ExpressionPolicy youngens = new ExpressionPolicy {
Name = "Young folk",
Properties = new string[] { }, // Can't do anything with this yet
Rules = new List<ExpressionRule>
{
("People", "number", ">=17").ToFilterRule(),
("People", "number", "<30").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
public static ExpressionPolicy vikings = new ExpressionPolicy {
Name = "Vikings",
Rules = new List<ExpressionRule>
{
("People", "kind", "~viking").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
public static ExpressionPolicy muggles = new ExpressionPolicy {
Name = "Non-magic folk",
Rules = new List<ExpressionRule>
{
("People", "tags", "muggle").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
public static ExpressionPolicy notQuiteDead = new ExpressionPolicy {
Rules = new List<ExpressionRule>
{
("People", "stillWithUs", "true").ToFilterRule(),
},
RuleOperator = RuleOperator.And
};
public static ExpressionPolicy deadOrViking = new ExpressionPolicy {
Rules = new List<ExpressionRule>
{
("People", "stillWithUs", "false").ToFilterRule(),
("People", "kind", "Viking").ToFilterRule(),
},
RuleOperator = RuleOperator.Or
};
#endregion testPolicies
#region someContext policies
public static ExpressionPolicy itPeople = new ExpressionPolicy {
Rules = new List<ExpressionRule>
{
("ContextDictionary", "Department", "IT").ToFilterRule(),
("ContextStringDictionary", "Department", "IT").ToFilterRule(),
("SomeContext", "Context.Department", "IT").ToFilterRule(), // Same rule but with nested selector
},
RuleOperator = RuleOperator.And
};
public static ExpressionPolicy itPeopleCaseless = new ExpressionPolicy {
Rules = new List<ExpressionRule>
{
("ContextDictionary", "Department", "~IT").ToFilterRule(),
("ContextStringDictionary", "Department", "~IT").ToFilterRule(),
("SomeContext", "Context.Department", "~IT").ToFilterRule(), // Same rule but with nested selector
},
RuleOperator = RuleOperator.And
};
#endregion
}
}

View File

@ -8,7 +8,7 @@
<UserSecretsId>63a98c68-03bd-4069-b6b9-e0978081c430</UserSecretsId>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<Title>McRule - Rule based expression generator</Title>
<Version>0.3.1</Version>
<Version>4.0.0</Version>
<Company>Sean McArdle</Company>
<Description>Library for generating expression trees from simple policy rules.</Description>
<Copyright>2023 Sean McArdle</Copyright>

View File

@ -34,6 +34,29 @@ public static partial class PredicateExpressionPolicyExtensions
return Expression.AndAlso(notNull, expression);
}
/// <summary>
/// Returns an expression which executes a ContainsKey method call on IDictionary types
/// and prepends it to a given expression with an AndAlso operator. Note, this comparison
/// short circuits so the right hand side will not execute when a key is not found.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="left"></param>
/// <param name="right"></param>
/// <param name="dictKey"></param>
/// <returns></returns>
internal static Expression AddContainsKeyCheck<T>(
Expression left,
string dictKey,
Expression<Func<T, bool>> right) {
// Create generic method which is bound with the Call Expression below
var containsKeyRuntimeMethod = left.Type.GetMethod("ContainsKey");
var containsKeyCall = Expression.Call(left, containsKeyRuntimeMethod, Expression.Constant(dictKey));
var methodExpression = Expression.Lambda<Func<T, bool>>(containsKeyCall, false, right.Parameters);
return PredicateBuilder.And<T>(methodExpression, right);
}
/// <summary>
/// Test for null value. This is used to test for null literals.
/// </summary>
@ -277,26 +300,80 @@ public class PolicyToEFExpressionGenerator : ExpressionGeneratorBase
}
}
public abstract class ExpressionGeneratorBase : ExpressionGenerator
{
public abstract class ExpressionGeneratorBase : ExpressionGenerator {
public virtual Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false)
{
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false) {
throw new NotImplementedException("Must override the AddStringPropertyExpression<T> method in a child class. This one is virtual and shouldn't ever be called.");
}
private class MemberResolveResult<T> {
internal List<Expression<Func<T, bool>>> PreChecks { get; set; } = new List<Expression<Func<T, bool>>>();
internal Expression Member { get; set; }
internal bool LOpIsDict { get; set; } = false;
internal Expression LOp { get; set; }
internal string DictKey { get; set; }
internal void AddNewPreCheck(Expression<Func<T, bool>> lambda) {
PreChecks.Add(lambda);
}
internal Expression<Func<T, bool>> GetPrecheckFunc() {
if (PreChecks.Count == 0) {
return default(Expression<Func<T, bool>>);
}
return CombineAnd<T>(PreChecks);
}
}
private MemberResolveResult<T> GetMemberByNameForType<T>(string propertyName, ParameterExpression parameter) {
var result = new MemberResolveResult<T>();
Expression opLeft = parameter;
foreach (string p in propertyName.Split(".")) {
result.LOpIsDict = false;
if (opLeft.Type.GetInterfaces().Contains(typeof(IDictionary))) {
result.LOpIsDict = true;
result.DictKey = p;
result.LOp = opLeft;
var dictKey = Expression.Constant(p);
opLeft = Expression.Property(opLeft, "Item", dictKey);
} else {
opLeft = Expression.PropertyOrField(opLeft, p);
}
}
result.Member = opLeft;
return result;
}
/// <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");
Expression opLeft = parameter;
foreach (string p in property.Split(".")) opLeft = Expression.PropertyOrField(opLeft, p);
var resolvedMember = GetMemberByNameForType<T>(property, parameter);
Expression opLeft = resolvedMember.Member;
(bool literalFound, LiteralValue? processedValue) = GetStringValueLiteral(value);
var opRight = Expression.Constant(value);
Expression? comparison = null;
Expression? comparison = resolvedMember.GetPrecheckFunc();
// 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.
@ -355,6 +432,7 @@ public abstract class ExpressionGeneratorBase : ExpressionGenerator
else if (value.StartsWith("*"))
{
comparison = AddStringPropertyExpression<T>(strParam, value.TrimStart('*'), "EndsWith", ignoreCase);
}
else if (value.EndsWith("*"))
{
@ -409,12 +487,24 @@ public abstract class ExpressionGeneratorBase : ExpressionGenerator
comparison = AddNotNullCheck<T>(opLeft, comparison);
}
// The value may have the right type and should just be returned.
if (comparison is Expression<Func<T, bool>> result && result != default(Expression<Func<T, bool>>)) {
return result;
// When the left hand side of the comparision implements IDictionary we need to add a ContainsKey
// method call to assert there's a value to compare against before actually retrieving it by name.
// A missing key evaluates to false.
// TODO: use ~ operator to return true for a comparison predicate where a key is missing.
if (resolvedMember.LOpIsDict) {
comparison = AddContainsKeyCheck<T>(resolvedMember.LOp, resolvedMember.DictKey, (Expression<Func<T, bool>> )comparison);
}
return Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);
// The value may have the right type and should just be returned.
Expression<Func<T, bool>> result = default(Expression<Func<T, bool>>);
if (comparison is Expression<Func<T, bool>> checkedResult && checkedResult != default(Expression<Func<T, bool>>)) {
result = checkedResult;
}
else {
result = Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);
}
return result;
}

View File

@ -18,6 +18,7 @@ A simple equality comparison is used by default but operators can be prefixed to
| IComparable | <>, !=, ! | Not-equal to comparison. |
> Note: the IComparable interface is mostly used for numerical types but custom types with comparison providers may work at runtime.
> Note: initial IDictionary support has been added but only for collections where the value types are strings. When missing keys are encountered, evaluation defaults to false.
### Literal Values
Literal values, as needed, use handlbar syntax: {{ value }}. Null checks are implicitly added to most expressions but sometimes you need an expression that evaluates true for null values. In that case, a null literal is represented as {{null}}.