Compare commits

...

12 Commits

6 changed files with 202 additions and 39 deletions

View File

@ -1,4 +1,5 @@
using Newtonsoft.Json.Linq;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Metadata;
@ -6,7 +7,7 @@ using System.Reflection.Metadata;
namespace McRule.Tests {
public class Filtering {
People[] things = new[] {
People[] peoples = new[] {
new People("Sean", "Confused", 35, true, new[] {"muggle"}),
new People("Sean", "Actor", 90, false, new[] {"muggle", "metallurgist"}),
new People("Bean", "Runt", 20, false, new[] {"muggle", "giant"}),
@ -15,6 +16,7 @@ namespace McRule.Tests {
new People("Ragnar", "Viking", 25, true, new[] {"muggle", "grumpy"}),
new People("Lars", "Viking", 30, false, new[] {"muggle", "grumpy"}),
new People("Ferris", "Student", 17, true, new[] {"muggle"}),
new People("Greta", null, 20, true, null),
};
public class People
@ -43,18 +45,37 @@ namespace McRule.Tests {
stillWithUs == other.stillWithUs &&
System.Linq.Enumerable.SequenceEqual(tags, other.tags);
}
public override int GetHashCode()
{
return System.HashCode.Combine(name, kind, number, stillWithUs, tags);
}
public override string ToString()
{
return $"People({name}, {kind}, {number}, {stillWithUs}, {string.Join(", ", tags ?? new string[0])})";
}
}
#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
@ -121,13 +142,48 @@ namespace McRule.Tests {
RuleOperator = PredicateExpressionPolicyExtensions.RuleOperator.Or
};
#endregion testPolicies
[SetUp]
public void Setup() { }
[Test]
public void MatchNullLiteral() {
var filter = matchNullLiteral.GetPredicateExpression<People>()?.Compile();
var folks = peoples.Where(filter);
Assert.IsTrue(folks.All(x => x.kind == null));
// Test using EF generator
var efGenerator = PredicateExpressionPolicyExtensions.GetEfExpressionGenerator();
var efFilter = matchNullLiteral.GetPredicateExpression<People>(efGenerator)?.Compile();
folks = peoples.Where(efFilter);
Assert.IsTrue(folks.All(x => x.kind == null));
}
[Test]
public void MatchNullByString() {
var expression = matchNullByString.GetPredicateExpression<People>();
var filter = expression?.Compile();
var folks = peoples.Where(filter);
Assert.Zero(folks.Count(), "A string value of null \"null\" should not evaluate to a null literal so should yield no results here.");
// Test using EF generator
var efGenerator = PredicateExpressionPolicyExtensions.GetEfExpressionGenerator();
var efFilter = matchNullByString.GetPredicateExpression<People>(efGenerator)?.Compile();
folks = peoples.Where(efFilter);
Assert.Zero(folks.Count(), "A string value of null \"null\" should not evaluate to a null literal so should yield no results here.");
}
[Test]
public void NegativeStringMatch() {
var filter = notSean.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
var folks = peoples.Where(filter);
Assert.Null(folks.FirstOrDefault(x => x.name == "Sean"));
}
@ -137,7 +193,7 @@ namespace McRule.Tests {
// Filter should match on people who's name ends in 'ean',
// and case insensitive ends with 'EAN'.
var filter = eans.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
var folks = peoples.Where(filter);
Assert.NotNull(folks);
Assert.IsTrue(folks.All(x => x.name.EndsWith("ean")));
@ -153,7 +209,7 @@ namespace McRule.Tests {
RuleOperator = PredicateExpressionPolicyExtensions.RuleOperator.And
}?.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
var folks = peoples.Where(filter);
// Match should be exclusive enough to only include Ragnar
Assert.NotNull(folks);
@ -163,11 +219,11 @@ namespace McRule.Tests {
// Process both expressions separately to verify they
// have different results.
filter = youngens.GetPredicateExpression<People>()?.Compile();
folks = things.Where(filter);
folks = peoples.Where(filter);
Assert.IsTrue(folks.Count() > 1);
filter = vikings.GetPredicateExpression<People>()?.Compile();
folks = things.Where(filter);
folks = peoples.Where(filter);
Assert.IsTrue(folks.Count() > 1);
// Original compound filter with an Or predicate
@ -178,7 +234,7 @@ namespace McRule.Tests {
RuleOperator = PredicateExpressionPolicyExtensions.RuleOperator.Or
}?.GetPredicateExpression<People>()?.Compile();
folks = things.Where(filter);
folks = peoples.Where(filter);
Assert.IsTrue(folks.Count() > 1);
// Should include Vikings by kind and Student by number
Assert.NotNull(folks.Where(x => x.kind == "Viking"));
@ -188,25 +244,37 @@ namespace McRule.Tests {
[Test]
public void YoungPeople() {
var filter = youngens.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
var folks = peoples.Where(filter);
Assert.NotNull(folks);
Assert.IsTrue(folks.All(x => x.number >= 17 && x.number < 30));
}
[Test]
public void FilterListOfObjectsByMemberCollectionContents() {
public void FilterListOfObjectsByMemberCollectionContents()
{
var generator = new PolicyToExpressionGenerator();
var generatedFilter = muggles.GetPredicateExpression<People>(generator);
var filteredFolks = peoples.Where(generatedFilter.Compile());
var efGenerator = new PolicyToEFExpressionGenerator();
var efGeneratedFilter = muggles.GetPredicateExpression<People>(efGenerator);
var efFilteredFolks = peoples.Where(efGeneratedFilter.Compile());
var filter = muggles.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
var folks = peoples.Where(filter);
Assert.NotNull(folks);
Assert.IsTrue(folks.All(x => x.tags.Contains("muggle")));
Assert.IsTrue(folks.All(x => filteredFolks.Contains(x)));
Assert.IsTrue(folks.All(x => efFilteredFolks.Contains(x)));
}
[Test]
public void BoolConditional() {
var filter = notQuiteDead.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
var folks = peoples.Where(filter);
Assert.NotNull(folks);
Assert.IsTrue(folks.Count() > 0);
@ -223,7 +291,7 @@ namespace McRule.Tests {
[Test]
public void TestPolicyWithOrConditional() {
var filter = deadOrViking.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
var folks = peoples.Where(filter);
Assert.NotNull(folks);
Assert.NotNull(folks.Where(x => x.kind == "Viking" && x.stillWithUs == false));
@ -233,6 +301,14 @@ namespace McRule.Tests {
Assert.Null(folks.FirstOrDefault(x => x.kind != "Viking" && x.stillWithUs == true));
}
[Test]
public void TestBooleanMatches() {
var filter = notQuiteDead.GetPredicateExpression<People>()?.Compile();
var folks = peoples.Where(filter);
Assert.IsTrue(folks.All(x => x.stillWithUs == true));
}
[Test]
public void PolicyEFExpressionShouldNotEmitComparisonTypeStringMatches() {
var filter = eans.GetPredicateExpression<People>();
@ -245,5 +321,19 @@ namespace McRule.Tests {
Assert.IsTrue(filter.ToString().Contains("CurrentCulture"));
Assert.IsFalse(efFilter.ToString().Contains("CurrentCulture"), "EF safe string comparision contains a CurrentCulture directive, wrong generator used for AddStringPropertyExpression.");
}
[Test]
public void BaseThrowsOnPoorlyImplementedGenerator()
{
var failedGenerator = new FailedExpressionGeneratorBase();
Assert.Throws<NotImplementedException>(() => { _ = eans.GetPredicateExpression<People>(failedGenerator); });
}
}
}
internal class FailedExpressionGeneratorBase : ExpressionGeneratorBase
{
public FailedExpressionGeneratorBase()
{
}
}
}

View File

@ -35,8 +35,6 @@ namespace McRule {
Expression? cachedExpression = null;
internal (string, string, string) Rule => (TargetType, Property, Value);
public ExpressionRule() { }
public ExpressionRule(string TargetType, string Property, string Value) {
@ -78,7 +76,7 @@ namespace McRule {
}
public string GetFilterString<T>() {
return this.GetPredicateExpression<T>()?.ToString() ?? String.Empty;
return GetPredicateExpression<T>()?.ToString() ?? String.Empty;
}
}
}

15
McRule/LiteralValue.cs Normal file
View File

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace McRule {
public class LiteralValue {
public dynamic Value { get; set; }
public override string ToString() => "BaseLiteral";
}
public class NullValue : LiteralValue {
public override string ToString() => "null";
}
}

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.2.0</Version>
<Version>0.2.2</Version>
<Company>Sean McArdle</Company>
<Description>Library for generating expression trees from simple policy rules.</Description>
<Copyright>2023 Sean McArdle</Copyright>
@ -27,4 +27,8 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
</ItemGroup>
</Project>

View File

@ -2,6 +2,7 @@
using System.Diagnostics.Contracts;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using static McRule.PredicateExpressionPolicyExtensions;
@ -23,7 +24,7 @@ public static partial class PredicateExpressionPolicyExtensions
/// <summary>
/// Prepend the given predicate with a short circuiting null check.
/// </summary>
internal static Expression AddNullCheck<T>(
internal static Expression AddNotNullCheck<T>(
Expression left,
Expression expression)
{
@ -33,6 +34,16 @@ public static partial class PredicateExpressionPolicyExtensions
return Expression.AndAlso(notNull, expression);
}
/// <summary>
/// Test for null value. This is used to test for null literals.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="expression"></param>
/// <returns></returns>
internal static Expression IsNull<T>(Expression expression) {
return Expression.Equal(expression, Expression.Constant(null));
}
/// <summary>
/// Applies negative predicate to expression in a lambda.
/// </summary>
@ -92,7 +103,7 @@ public static partial class PredicateExpressionPolicyExtensions
//LambdaExpression
var containsCall = Expression.Call(arrContainsRuntimeMethod, opLeft, opRight);
var finalExpression = AddNullCheck<T>(opLeft, containsCall);
var finalExpression = AddNotNullCheck<T>(opLeft, containsCall);
// Wrap it up in a warm lambda snuggie
return Expression.Lambda<Func<T, bool>>(finalExpression, false, parameter);
@ -184,6 +195,27 @@ public static partial class PredicateExpressionPolicyExtensions
lambda.Parameters);
}
static Regex handlebarPattern = new Regex(@"^(\{\{)(?<literal>.+)(\}\})", RegexOptions.ExplicitCapture
| RegexOptions.Compiled);
internal static (bool,LiteralValue?) GetStringValueLiteral(string value) {
/* Literal values are contained within handlebar syntax {{ literal }}
* so values should be added to the switch statement below. Matching
* cases should return early with their literal value.
* */
var matched = handlebarPattern.Match(value);
if (matched.Success) {
switch (matched.Groups.FirstOrDefault(x => x.Name == "literal")?.Value?.Trim()?.ToLower()) {
case "null":
return (true, new NullValue());
break;
}
}
// No literals found.
return (false, null);
}
/// <summary>
/// Dynamically build an expression suitable for filtering in a Where clause
/// </summary>
@ -191,6 +223,7 @@ public static partial class PredicateExpressionPolicyExtensions
{
var parameter = Expression.Parameter(typeof(T), "x");
var opLeft = Expression.Property(parameter, property);
(bool literalFound, LiteralValue? processedValue) = GetStringValueLiteral(value);
var opRight = Expression.Constant(value);
Expression? comparison = null;
@ -213,6 +246,11 @@ public static partial class PredicateExpressionPolicyExtensions
hasComparable = lType.GetInterface("IComparable");
}
if (literalFound) {
if (processedValue is NullValue) {
return Expression.Lambda<Func<T, bool>>(IsNull<T>(opLeft), parameter);
}
}
// For string comparisons using wildcards, trim the wildcard characters and pass to the comparison method
if (lType == typeof(string))
@ -264,9 +302,11 @@ public static partial class PredicateExpressionPolicyExtensions
}
return result;
}
else if (hasCollection == typeof(ICollection)) {
return GetArrayContainsExpression<T>(property, value);
}
else if (hasComparable == typeof(IComparable))
{
else if (hasComparable == typeof(IComparable)) {
var operatorPrefix = Regex.Match(value.Trim(), @"^[!<>=]+");
var operand = (operatorPrefix.Success ? value.Replace(operatorPrefix.Value, "") : value).Trim();
@ -285,10 +325,6 @@ public static partial class PredicateExpressionPolicyExtensions
comparison = GetComparer(operatorPrefix.Value.Trim(), opLeftFinal, opRight);
}
}
else if (hasCollection == typeof(ICollection))
{
return GetArrayContainsExpression<T>(property, value);
}
else
{
comparison = Expression.Equal(opLeft, opRight);
@ -300,7 +336,7 @@ public static partial class PredicateExpressionPolicyExtensions
comparison = comparison == null ? falsePredicate : comparison;
if (isNullable)
{
comparison = AddNullCheck<T>(opLeft, comparison);
comparison = AddNotNullCheck<T>(opLeft, comparison);
}
return Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);
@ -373,6 +409,7 @@ public abstract class ExpressionGeneratorBase : ExpressionGenerator
{
var parameter = Expression.Parameter(typeof(T), "x");
var opLeft = Expression.Property(parameter, property);
(bool literalFound, LiteralValue? processedValue) = GetStringValueLiteral(value);
var opRight = Expression.Constant(value);
Expression? comparison = null;
@ -385,8 +422,7 @@ public abstract class ExpressionGeneratorBase : ExpressionGenerator
var isNullable = false;
Type? hasComparable = lType.GetInterface("IComparable");
Type? hasCollection = lType.GetInterface("ICollection");
if (hasComparable == null && opLeft.Type.IsValueType)
{
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.
@ -395,6 +431,12 @@ public abstract class ExpressionGeneratorBase : ExpressionGenerator
hasComparable = lType.GetInterface("IComparable");
}
if (literalFound) {
if (processedValue == null) {
return Expression.Lambda<Func<T, bool>>(IsNull<T>(opLeft), parameter);
}
}
// For string comparisons using wildcards, trim the wildcard characters and pass to the comparison method
if (lType == typeof(string))
@ -482,7 +524,7 @@ public abstract class ExpressionGeneratorBase : ExpressionGenerator
comparison = comparison == null ? falsePredicate : comparison;
if (isNullable)
{
comparison = AddNullCheck<T>(opLeft, comparison);
comparison = AddNotNullCheck<T>(opLeft, comparison);
}
return Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);

View File

@ -19,6 +19,20 @@ A simple equality comparison is used by default but operators can be prefixed to
> Note: the IComparable interface is mostly used for numerical types but custom types with comparison providers may work at runtime.
### 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}}.
Case sensitivity doesn't matter, nor does internal whitespace inside the braces. Values are interpretted like so:
```csharp
var matched = handlebarPattern.Match(value);
if (matched.Success) {
switch (matched.Groups.FirstOrDefault(x => x.Name == "literal")?.Value?.Trim()?.ToLower()) {
case "null":
return (true, new NullValue());
break;
}
}
```
### Examples