Skip to content

Commit

Permalink
feat: Support contains operator with many to one multiplicity (#165)
Browse files Browse the repository at this point in the history
* feat: support contains operator with many to one multiplicity

* fix: fix codacy issue

* fix: fix codacy issue
  • Loading branch information
luispfgarces authored Apr 24, 2024
1 parent e56fe84 commit 324fee7
Show file tree
Hide file tree
Showing 19 changed files with 457 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@ public ConditionExpressionBuilderProvider()
{
this.conditionExpressionBuilders = new Dictionary<string, IConditionExpressionBuilder>(StringComparer.Ordinal)
{
{ Combine(Operators.CaseInsensitiveEndsWith, Multiplicities.OneToOne), new CaseInsensitiveEndsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.CaseInsensitiveStartsWith, Multiplicities.OneToOne), new CaseInsensitiveStartsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.Contains, Multiplicities.ManyToOne), new ContainsManyToOneConditionExpressionBuilder() },
{ Combine(Operators.Contains, Multiplicities.OneToOne), new ContainsOneToOneConditionExpressionBuilder() },
{ Combine(Operators.EndsWith, Multiplicities.OneToOne), new EndsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.Equal, Multiplicities.OneToOne), new EqualOneToOneConditionExpressionBuilder() },
{ Combine(Operators.NotEqual, Multiplicities.OneToOne), new NotEqualOneToOneConditionExpressionBuilder() },
{ Combine(Operators.GreaterThan, Multiplicities.OneToOne), new GreaterThanOneToOneConditionExpressionBuilder() },
{ Combine(Operators.GreaterThanOrEqual, Multiplicities.OneToOne), new GreaterThanOrEqualOneToOneConditionExpressionBuilder() },
{ Combine(Operators.In, Multiplicities.OneToMany), new InOneToManyConditionExpressionBuilder() },
{ Combine(Operators.LesserThan, Multiplicities.OneToOne), new LesserThanOneToOneConditionExpressionBuilder() },
{ Combine(Operators.LesserThanOrEqual, Multiplicities.OneToOne), new LesserThanOrEqualOneToOneConditionExpressionBuilder() },
{ Combine(Operators.Contains, Multiplicities.OneToOne), new ContainsOneToOneConditionExpressionBuilder() },
{ Combine(Operators.NotContains, Multiplicities.OneToOne), new NotContainsOneToOneConditionExpressionBuilder() },
{ Combine(Operators.In, Multiplicities.OneToMany), new InOneToManyConditionExpressionBuilder() },
{ Combine(Operators.StartsWith, Multiplicities.OneToOne), new StartsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.EndsWith, Multiplicities.OneToOne), new EndsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.CaseInsensitiveStartsWith, Multiplicities.OneToOne), new CaseInsensitiveStartsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.CaseInsensitiveEndsWith, Multiplicities.OneToOne), new CaseInsensitiveEndsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.NotEndsWith, Multiplicities.OneToOne), new NotEndsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.NotStartsWith, Multiplicities.OneToOne), new NotStartsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.NotEqual, Multiplicities.OneToOne), new NotEqualOneToOneConditionExpressionBuilder() },
{ Combine(Operators.NotIn, Multiplicities.OneToMany), new NotInOneToManyConditionExpressionBuilder() },
{ Combine(Operators.NotStartsWith, Multiplicities.OneToOne), new NotStartsWithOneToOneConditionExpressionBuilder() },
{ Combine(Operators.StartsWith, Multiplicities.OneToOne), new StartsWithOneToOneConditionExpressionBuilder() },
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Rules.Framework.Evaluation.Compiled.ConditionBuilders
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Rules.Framework.Core;
using Rules.Framework.Evaluation.Compiled.ExpressionBuilders;

internal sealed class ContainsManyToOneConditionExpressionBuilder : IConditionExpressionBuilder
{
private static readonly Dictionary<Type, MethodInfo> containsLinqGenericMethodInfos = InitializeLinqContainsMethodInfos();
private static readonly DataTypes[] supportedDataTypes = { DataTypes.Boolean, DataTypes.Decimal, DataTypes.Integer, DataTypes.String };

public Expression BuildConditionExpression(IExpressionBlockBuilder builder, BuildConditionExpressionArgs args)
{
if (!supportedDataTypes.Contains(args.DataTypeConfiguration.DataType))
{
throw new NotSupportedException(
$"The operator '{nameof(Operators.Contains)}' is not supported for data type '{args.DataTypeConfiguration.DataType}' on a many to one scenario.");
}

var containsMethodInfo = containsLinqGenericMethodInfos[args.DataTypeConfiguration.Type];

return builder.AndAlso(
builder.NotEqual(args.LeftHandOperand, builder.Constant<object>(value: null!)),
builder.Call(
null!,
containsMethodInfo,
new Expression[] { args.LeftHandOperand, args.RightHandOperand }));
}

private static Dictionary<Type, MethodInfo> InitializeLinqContainsMethodInfos()
{
var genericMethodInfo = typeof(Enumerable)
.GetMethods()
.First(m => string.Equals(m.Name, nameof(Enumerable.Contains), StringComparison.Ordinal) && m.GetParameters().Length == 2);

return new Dictionary<Type, MethodInfo>
{
{ typeof(bool), genericMethodInfo.MakeGenericMethod(typeof(bool)) },
{ typeof(decimal), genericMethodInfo.MakeGenericMethod(typeof(decimal)) },
{ typeof(int), genericMethodInfo.MakeGenericMethod(typeof(int)) },
{ typeof(string), genericMethodInfo.MakeGenericMethod(typeof(string)) },
};
}
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
namespace Rules.Framework.Evaluation.Compiled.ConditionBuilders
{
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Rules.Framework.Core;
using Rules.Framework.Evaluation.Compiled.ExpressionBuilders;

internal sealed class ContainsOneToOneConditionExpressionBuilder : IConditionExpressionBuilder
{
private static readonly MethodInfo stringContainsMethodInfo = typeof(string).GetMethod("Contains", new[] { typeof(string) });
private static readonly MethodInfo stringContainsMethodInfo = typeof(string).GetMethod(nameof(Enumerable.Contains), new[] { typeof(string) });

public Expression BuildConditionExpression(IExpressionBlockBuilder builder, BuildConditionExpressionArgs args)
{
if (args.DataTypeConfiguration.DataType != DataTypes.String)
{
throw new NotSupportedException($"The operator '{Operators.Contains}' is not supported for data type '{args.DataTypeConfiguration.DataType}'.");
throw new NotSupportedException(
$"The operator '{nameof(Operators.Contains)}' is not supported for data type '{args.DataTypeConfiguration.DataType}' on a one to one scenario.");
}

return builder.AndAlso(
builder.NotEqual(args.LeftHandOperand, builder.Constant<object>(value: null)),
builder.NotEqual(args.LeftHandOperand, builder.Constant<object>(value: null!)),
builder.Call(
args.LeftHandOperand,
stringContainsMethodInfo,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
namespace Rules.Framework.Evaluation.Interpreted.ValueEvaluation
{
using System;
using System.Collections.Generic;
using System.Linq;

internal sealed class ContainsOperatorEvalStrategy : IOneToOneOperatorEvalStrategy
internal sealed class ContainsOperatorEvalStrategy : IOneToOneOperatorEvalStrategy, IManyToOneOperatorEvalStrategy
{
public bool Eval(object leftOperand, object rightOperand)
{
if (leftOperand is string)
{
string leftOperandAsString = leftOperand as string;
string rightOperandAsString = rightOperand as string;
var leftOperandAsString = leftOperand as string;
var rightOperandAsString = rightOperand as string;

return leftOperandAsString.Contains(rightOperandAsString);
#if NETSTANDARD2_1_OR_GREATER
return leftOperandAsString!.Contains(rightOperandAsString, StringComparison.Ordinal);
#else
return leftOperandAsString!.Contains(rightOperandAsString);
#endif
}

throw new NotSupportedException($"Unsupported 'contains' comparison between operands of type '{leftOperand?.GetType().FullName}'.");
}

public bool Eval(IEnumerable<object> leftOperand, object rightOperand)
=> leftOperand.Contains(rightOperand);
}
}
2 changes: 1 addition & 1 deletion src/Rules.Framework/Evaluation/OperatorsMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ static OperatorsMetadata()
public static OperatorMetadata Contains => new()
{
Operator = Operators.Contains,
SupportedMultiplicities = new[] { Multiplicities.OneToOne },
SupportedMultiplicities = new[] { Multiplicities.OneToOne, Multiplicities.ManyToOne },
};

public static OperatorMetadata EndsWith => new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit" Version="2.7.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace Rules.Framework.Providers.InMemory.IntegrationTests.Features.RulesEngi
{
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Rules.Framework.Core;
using Rules.Framework.IntegrationTests.Common.Features;
Expand All @@ -15,7 +16,19 @@ protected RulesEngineTestsBase(ContentType testContentType)
{
this.TestContentType = testContentType;

this.RulesEngine = RulesEngineBuilder
this.CompiledRulesEngine = RulesEngineBuilder
.CreateRulesEngine()
.WithContentType<ContentType>()
.WithConditionType<ConditionType>()
.SetInMemoryDataSource()
.Configure(c =>
{
c.EnableCompilation = true;
c.PriorityCriteria = PriorityCriterias.TopmostRuleWins;
})
.Build();

this.InterpretedRulesEngine = RulesEngineBuilder
.CreateRulesEngine()
.WithContentType<ContentType>()
.WithConditionType<ConditionType>()
Expand All @@ -24,27 +37,76 @@ protected RulesEngineTestsBase(ContentType testContentType)
.Build();
}

protected RulesEngine<ContentType, ConditionType> RulesEngine { get; }
protected RulesEngine<ContentType, ConditionType> CompiledRulesEngine { get; }

protected RulesEngine<ContentType, ConditionType> InterpretedRulesEngine { get; }

protected async Task<RuleOperationResult> ActivateRuleAsync(Rule<ContentType, ConditionType> rule, bool compiled)
{
if (compiled)
{
return await CompiledRulesEngine.ActivateRuleAsync(rule);
}
else
{
return await InterpretedRulesEngine.ActivateRuleAsync(rule);
}
}

protected void AddRules(IEnumerable<RuleSpecification> ruleSpecifications)
{
foreach (var ruleSpecification in ruleSpecifications)
{
this.RulesEngine.AddRuleAsync(
ruleSpecification.Rule,
ruleSpecification.RuleAddPriorityOption)
.ConfigureAwait(false)
this.CompiledRulesEngine.AddRuleAsync(ruleSpecification.Rule, ruleSpecification.RuleAddPriorityOption)
.GetAwaiter()
.GetResult();

this.InterpretedRulesEngine.AddRuleAsync(ruleSpecification.Rule, ruleSpecification.RuleAddPriorityOption)
.GetAwaiter()
.GetResult();
}
}

protected async Task<RuleOperationResult> DeactivateRuleAsync(Rule<ContentType, ConditionType> rule, bool compiled)
{
if (compiled)
{
return await CompiledRulesEngine.DeactivateRuleAsync(rule);
}
else
{
return await InterpretedRulesEngine.DeactivateRuleAsync(rule);
}
}

protected async Task<Rule<ContentType, ConditionType>> MatchOneAsync(
DateTime matchDate,
Condition<ConditionType>[] conditions) => await RulesEngine.MatchOneAsync(
TestContentType,
matchDate,
conditions)
.ConfigureAwait(false);
Condition<ConditionType>[] conditions,
bool compiled)
{
if (compiled)
{
return await CompiledRulesEngine.MatchOneAsync(TestContentType, matchDate, conditions);
}
else
{
return await InterpretedRulesEngine.MatchOneAsync(TestContentType, matchDate, conditions);
}
}

protected async Task<RuleOperationResult> UpdateRuleAsync(Rule<ContentType, ConditionType> rule, bool compiled)
{
if (compiled)
{
return await CompiledRulesEngine.UpdateRuleAsync(rule);
}
else
{
return await InterpretedRulesEngine.UpdateRuleAsync(rule);
}
}

protected DateTime UtcDate(string date)
=> DateTime.Parse(date, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace Rules.Framework.Providers.InMemory.IntegrationTests.Features.RulesEngine.RulesMatching
{
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using Rules.Framework.Core;
using Rules.Framework.IntegrationTests.Common.Features;
using Rules.Framework.Tests.Stubs;
using Xunit;

public class OperatorContainsManyToOneTests : RulesEngineTestsBase
{
private static readonly ContentType testContentType = ContentType.ContentType1;
private readonly Rule<ContentType, ConditionType> expectedMatchRule;
private readonly Rule<ContentType, ConditionType> otherRule;

public OperatorContainsManyToOneTests()
: base(testContentType)
{
this.expectedMatchRule = RuleBuilder.NewRule<ContentType, ConditionType>()
.WithName("Expected rule")
.WithDateBegin(UtcDate("2020-01-01Z"))
.WithContent(testContentType, "Just as expected!")
.WithCondition(ConditionType.ConditionType1, Operators.Contains, "Cat")
.Build()
.Rule;

this.otherRule = RuleBuilder.NewRule<ContentType, ConditionType>()
.WithName("Other rule")
.WithDateBegin(UtcDate("2020-01-01Z"))
.WithContent(testContentType, "Oops! Not expected to be matched.")
.Build()
.Rule;

this.AddRules(this.CreateTestRules());
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RulesEngine_GivenConditionType1WithArrayOfStringsContainingCat_MatchesExpectedRule(bool compiled)
{
// Arrange
var emptyConditions = new[]
{
new Condition<ConditionType>(ConditionType.ConditionType1, new[]{ "Dog", "Fish", "Cat", "Spider", "Mockingbird", })
};
var matchDate = UtcDate("2020-01-02Z");

// Act
var actualMatch = await this.MatchOneAsync(matchDate, emptyConditions, compiled);

// Assert
actualMatch.Should().BeEquivalentTo(expectedMatchRule);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RulesEngine_GivenConditionType1WithArrayOfStringsNotContainingCat_MatchesOtherRule(bool compiled)
{
// Arrange
var emptyConditions = new[]
{
new Condition<ConditionType>(ConditionType.ConditionType1, new[]{ "Dog", "Fish", "Bat", "Spider", "Mockingbird", })
};
var matchDate = UtcDate("2020-01-02Z");

// Act
var actualMatch = await this.MatchOneAsync(matchDate, emptyConditions, compiled);

// Assert
actualMatch.Should().BeEquivalentTo(otherRule);
}

private IEnumerable<RuleSpecification> CreateTestRules()
{
var ruleSpecs = new List<RuleSpecification>
{
new RuleSpecification(expectedMatchRule, RuleAddPriorityOption.ByPriorityNumber(1)),
new RuleSpecification(otherRule, RuleAddPriorityOption.ByPriorityNumber(2))
};

return ruleSpecs;
}
}
}
Loading

0 comments on commit 324fee7

Please sign in to comment.