Skip to content

Commit 324fee7

Browse files
authored
feat: Support contains operator with many to one multiplicity (#165)
* feat: support contains operator with many to one multiplicity * fix: fix codacy issue * fix: fix codacy issue
1 parent e56fe84 commit 324fee7

File tree

19 files changed

+457
-83
lines changed

19 files changed

+457
-83
lines changed

src/Rules.Framework/Evaluation/Compiled/ConditionBuilders/ConditionExpressionBuilderProvider.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,23 @@ public ConditionExpressionBuilderProvider()
1212
{
1313
this.conditionExpressionBuilders = new Dictionary<string, IConditionExpressionBuilder>(StringComparer.Ordinal)
1414
{
15+
{ Combine(Operators.CaseInsensitiveEndsWith, Multiplicities.OneToOne), new CaseInsensitiveEndsWithOneToOneConditionExpressionBuilder() },
16+
{ Combine(Operators.CaseInsensitiveStartsWith, Multiplicities.OneToOne), new CaseInsensitiveStartsWithOneToOneConditionExpressionBuilder() },
17+
{ Combine(Operators.Contains, Multiplicities.ManyToOne), new ContainsManyToOneConditionExpressionBuilder() },
18+
{ Combine(Operators.Contains, Multiplicities.OneToOne), new ContainsOneToOneConditionExpressionBuilder() },
19+
{ Combine(Operators.EndsWith, Multiplicities.OneToOne), new EndsWithOneToOneConditionExpressionBuilder() },
1520
{ Combine(Operators.Equal, Multiplicities.OneToOne), new EqualOneToOneConditionExpressionBuilder() },
16-
{ Combine(Operators.NotEqual, Multiplicities.OneToOne), new NotEqualOneToOneConditionExpressionBuilder() },
1721
{ Combine(Operators.GreaterThan, Multiplicities.OneToOne), new GreaterThanOneToOneConditionExpressionBuilder() },
1822
{ Combine(Operators.GreaterThanOrEqual, Multiplicities.OneToOne), new GreaterThanOrEqualOneToOneConditionExpressionBuilder() },
23+
{ Combine(Operators.In, Multiplicities.OneToMany), new InOneToManyConditionExpressionBuilder() },
1924
{ Combine(Operators.LesserThan, Multiplicities.OneToOne), new LesserThanOneToOneConditionExpressionBuilder() },
2025
{ Combine(Operators.LesserThanOrEqual, Multiplicities.OneToOne), new LesserThanOrEqualOneToOneConditionExpressionBuilder() },
21-
{ Combine(Operators.Contains, Multiplicities.OneToOne), new ContainsOneToOneConditionExpressionBuilder() },
2226
{ Combine(Operators.NotContains, Multiplicities.OneToOne), new NotContainsOneToOneConditionExpressionBuilder() },
23-
{ Combine(Operators.In, Multiplicities.OneToMany), new InOneToManyConditionExpressionBuilder() },
24-
{ Combine(Operators.StartsWith, Multiplicities.OneToOne), new StartsWithOneToOneConditionExpressionBuilder() },
25-
{ Combine(Operators.EndsWith, Multiplicities.OneToOne), new EndsWithOneToOneConditionExpressionBuilder() },
26-
{ Combine(Operators.CaseInsensitiveStartsWith, Multiplicities.OneToOne), new CaseInsensitiveStartsWithOneToOneConditionExpressionBuilder() },
27-
{ Combine(Operators.CaseInsensitiveEndsWith, Multiplicities.OneToOne), new CaseInsensitiveEndsWithOneToOneConditionExpressionBuilder() },
2827
{ Combine(Operators.NotEndsWith, Multiplicities.OneToOne), new NotEndsWithOneToOneConditionExpressionBuilder() },
29-
{ Combine(Operators.NotStartsWith, Multiplicities.OneToOne), new NotStartsWithOneToOneConditionExpressionBuilder() },
28+
{ Combine(Operators.NotEqual, Multiplicities.OneToOne), new NotEqualOneToOneConditionExpressionBuilder() },
3029
{ Combine(Operators.NotIn, Multiplicities.OneToMany), new NotInOneToManyConditionExpressionBuilder() },
30+
{ Combine(Operators.NotStartsWith, Multiplicities.OneToOne), new NotStartsWithOneToOneConditionExpressionBuilder() },
31+
{ Combine(Operators.StartsWith, Multiplicities.OneToOne), new StartsWithOneToOneConditionExpressionBuilder() },
3132
};
3233
}
3334

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace Rules.Framework.Evaluation.Compiled.ConditionBuilders
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Linq.Expressions;
7+
using System.Reflection;
8+
using Rules.Framework.Core;
9+
using Rules.Framework.Evaluation.Compiled.ExpressionBuilders;
10+
11+
internal sealed class ContainsManyToOneConditionExpressionBuilder : IConditionExpressionBuilder
12+
{
13+
private static readonly Dictionary<Type, MethodInfo> containsLinqGenericMethodInfos = InitializeLinqContainsMethodInfos();
14+
private static readonly DataTypes[] supportedDataTypes = { DataTypes.Boolean, DataTypes.Decimal, DataTypes.Integer, DataTypes.String };
15+
16+
public Expression BuildConditionExpression(IExpressionBlockBuilder builder, BuildConditionExpressionArgs args)
17+
{
18+
if (!supportedDataTypes.Contains(args.DataTypeConfiguration.DataType))
19+
{
20+
throw new NotSupportedException(
21+
$"The operator '{nameof(Operators.Contains)}' is not supported for data type '{args.DataTypeConfiguration.DataType}' on a many to one scenario.");
22+
}
23+
24+
var containsMethodInfo = containsLinqGenericMethodInfos[args.DataTypeConfiguration.Type];
25+
26+
return builder.AndAlso(
27+
builder.NotEqual(args.LeftHandOperand, builder.Constant<object>(value: null!)),
28+
builder.Call(
29+
null!,
30+
containsMethodInfo,
31+
new Expression[] { args.LeftHandOperand, args.RightHandOperand }));
32+
}
33+
34+
private static Dictionary<Type, MethodInfo> InitializeLinqContainsMethodInfos()
35+
{
36+
var genericMethodInfo = typeof(Enumerable)
37+
.GetMethods()
38+
.First(m => string.Equals(m.Name, nameof(Enumerable.Contains), StringComparison.Ordinal) && m.GetParameters().Length == 2);
39+
40+
return new Dictionary<Type, MethodInfo>
41+
{
42+
{ typeof(bool), genericMethodInfo.MakeGenericMethod(typeof(bool)) },
43+
{ typeof(decimal), genericMethodInfo.MakeGenericMethod(typeof(decimal)) },
44+
{ typeof(int), genericMethodInfo.MakeGenericMethod(typeof(int)) },
45+
{ typeof(string), genericMethodInfo.MakeGenericMethod(typeof(string)) },
46+
};
47+
}
48+
}
49+
}

src/Rules.Framework/Evaluation/Compiled/ConditionBuilders/ContainsOneToOneConditionExpressionBuilder.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
namespace Rules.Framework.Evaluation.Compiled.ConditionBuilders
22
{
33
using System;
4+
using System.Linq;
45
using System.Linq.Expressions;
56
using System.Reflection;
67
using Rules.Framework.Core;
78
using Rules.Framework.Evaluation.Compiled.ExpressionBuilders;
89

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

1314
public Expression BuildConditionExpression(IExpressionBlockBuilder builder, BuildConditionExpressionArgs args)
1415
{
1516
if (args.DataTypeConfiguration.DataType != DataTypes.String)
1617
{
17-
throw new NotSupportedException($"The operator '{Operators.Contains}' is not supported for data type '{args.DataTypeConfiguration.DataType}'.");
18+
throw new NotSupportedException(
19+
$"The operator '{nameof(Operators.Contains)}' is not supported for data type '{args.DataTypeConfiguration.DataType}' on a one to one scenario.");
1820
}
1921

2022
return builder.AndAlso(
21-
builder.NotEqual(args.LeftHandOperand, builder.Constant<object>(value: null)),
23+
builder.NotEqual(args.LeftHandOperand, builder.Constant<object>(value: null!)),
2224
builder.Call(
2325
args.LeftHandOperand,
2426
stringContainsMethodInfo,
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
namespace Rules.Framework.Evaluation.Interpreted.ValueEvaluation
22
{
33
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
46

5-
internal sealed class ContainsOperatorEvalStrategy : IOneToOneOperatorEvalStrategy
7+
internal sealed class ContainsOperatorEvalStrategy : IOneToOneOperatorEvalStrategy, IManyToOneOperatorEvalStrategy
68
{
79
public bool Eval(object leftOperand, object rightOperand)
810
{
911
if (leftOperand is string)
1012
{
11-
string leftOperandAsString = leftOperand as string;
12-
string rightOperandAsString = rightOperand as string;
13+
var leftOperandAsString = leftOperand as string;
14+
var rightOperandAsString = rightOperand as string;
1315

14-
return leftOperandAsString.Contains(rightOperandAsString);
16+
#if NETSTANDARD2_1_OR_GREATER
17+
return leftOperandAsString!.Contains(rightOperandAsString, StringComparison.Ordinal);
18+
#else
19+
return leftOperandAsString!.Contains(rightOperandAsString);
20+
#endif
1521
}
1622

1723
throw new NotSupportedException($"Unsupported 'contains' comparison between operands of type '{leftOperand?.GetType().FullName}'.");
1824
}
25+
26+
public bool Eval(IEnumerable<object> leftOperand, object rightOperand)
27+
=> leftOperand.Contains(rightOperand);
1928
}
2029
}

src/Rules.Framework/Evaluation/OperatorsMetadata.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ static OperatorsMetadata()
7676
public static OperatorMetadata Contains => new()
7777
{
7878
Operator = Operators.Contains,
79-
SupportedMultiplicities = new[] { Multiplicities.OneToOne },
79+
SupportedMultiplicities = new[] { Multiplicities.OneToOne, Multiplicities.ManyToOne },
8080
};
8181

8282
public static OperatorMetadata EndsWith => new()

tests/Rules.Framework.IntegrationTests/Rules.Framework.IntegrationTests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<PackageReference Include="FluentAssertions" Version="6.10.0" />
2424
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
2525
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
26-
<PackageReference Include="xunit" Version="2.4.2" />
26+
<PackageReference Include="xunit" Version="2.7.1" />
2727
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
2828
<PrivateAssets>all</PrivateAssets>
2929
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

tests/Rules.Framework.Providers.InMemory.IntegrationTests/Features/RulesEngine/RulesEngineTestsBase.cs

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ namespace Rules.Framework.Providers.InMemory.IntegrationTests.Features.RulesEngi
22
{
33
using System;
44
using System.Collections.Generic;
5+
using System.Globalization;
56
using System.Threading.Tasks;
67
using Rules.Framework.Core;
78
using Rules.Framework.IntegrationTests.Common.Features;
@@ -15,7 +16,19 @@ protected RulesEngineTestsBase(ContentType testContentType)
1516
{
1617
this.TestContentType = testContentType;
1718

18-
this.RulesEngine = RulesEngineBuilder
19+
this.CompiledRulesEngine = RulesEngineBuilder
20+
.CreateRulesEngine()
21+
.WithContentType<ContentType>()
22+
.WithConditionType<ConditionType>()
23+
.SetInMemoryDataSource()
24+
.Configure(c =>
25+
{
26+
c.EnableCompilation = true;
27+
c.PriorityCriteria = PriorityCriterias.TopmostRuleWins;
28+
})
29+
.Build();
30+
31+
this.InterpretedRulesEngine = RulesEngineBuilder
1932
.CreateRulesEngine()
2033
.WithContentType<ContentType>()
2134
.WithConditionType<ConditionType>()
@@ -24,27 +37,76 @@ protected RulesEngineTestsBase(ContentType testContentType)
2437
.Build();
2538
}
2639

27-
protected RulesEngine<ContentType, ConditionType> RulesEngine { get; }
40+
protected RulesEngine<ContentType, ConditionType> CompiledRulesEngine { get; }
41+
42+
protected RulesEngine<ContentType, ConditionType> InterpretedRulesEngine { get; }
43+
44+
protected async Task<RuleOperationResult> ActivateRuleAsync(Rule<ContentType, ConditionType> rule, bool compiled)
45+
{
46+
if (compiled)
47+
{
48+
return await CompiledRulesEngine.ActivateRuleAsync(rule);
49+
}
50+
else
51+
{
52+
return await InterpretedRulesEngine.ActivateRuleAsync(rule);
53+
}
54+
}
2855

2956
protected void AddRules(IEnumerable<RuleSpecification> ruleSpecifications)
3057
{
3158
foreach (var ruleSpecification in ruleSpecifications)
3259
{
33-
this.RulesEngine.AddRuleAsync(
34-
ruleSpecification.Rule,
35-
ruleSpecification.RuleAddPriorityOption)
36-
.ConfigureAwait(false)
60+
this.CompiledRulesEngine.AddRuleAsync(ruleSpecification.Rule, ruleSpecification.RuleAddPriorityOption)
3761
.GetAwaiter()
3862
.GetResult();
63+
64+
this.InterpretedRulesEngine.AddRuleAsync(ruleSpecification.Rule, ruleSpecification.RuleAddPriorityOption)
65+
.GetAwaiter()
66+
.GetResult();
67+
}
68+
}
69+
70+
protected async Task<RuleOperationResult> DeactivateRuleAsync(Rule<ContentType, ConditionType> rule, bool compiled)
71+
{
72+
if (compiled)
73+
{
74+
return await CompiledRulesEngine.DeactivateRuleAsync(rule);
75+
}
76+
else
77+
{
78+
return await InterpretedRulesEngine.DeactivateRuleAsync(rule);
3979
}
4080
}
4181

4282
protected async Task<Rule<ContentType, ConditionType>> MatchOneAsync(
4383
DateTime matchDate,
44-
Condition<ConditionType>[] conditions) => await RulesEngine.MatchOneAsync(
45-
TestContentType,
46-
matchDate,
47-
conditions)
48-
.ConfigureAwait(false);
84+
Condition<ConditionType>[] conditions,
85+
bool compiled)
86+
{
87+
if (compiled)
88+
{
89+
return await CompiledRulesEngine.MatchOneAsync(TestContentType, matchDate, conditions);
90+
}
91+
else
92+
{
93+
return await InterpretedRulesEngine.MatchOneAsync(TestContentType, matchDate, conditions);
94+
}
95+
}
96+
97+
protected async Task<RuleOperationResult> UpdateRuleAsync(Rule<ContentType, ConditionType> rule, bool compiled)
98+
{
99+
if (compiled)
100+
{
101+
return await CompiledRulesEngine.UpdateRuleAsync(rule);
102+
}
103+
else
104+
{
105+
return await InterpretedRulesEngine.UpdateRuleAsync(rule);
106+
}
107+
}
108+
109+
protected DateTime UtcDate(string date)
110+
=> DateTime.Parse(date, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal);
49111
}
50112
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
namespace Rules.Framework.Providers.InMemory.IntegrationTests.Features.RulesEngine.RulesMatching
2+
{
3+
using System.Collections.Generic;
4+
using System.Threading.Tasks;
5+
using FluentAssertions;
6+
using Rules.Framework.Core;
7+
using Rules.Framework.IntegrationTests.Common.Features;
8+
using Rules.Framework.Tests.Stubs;
9+
using Xunit;
10+
11+
public class OperatorContainsManyToOneTests : RulesEngineTestsBase
12+
{
13+
private static readonly ContentType testContentType = ContentType.ContentType1;
14+
private readonly Rule<ContentType, ConditionType> expectedMatchRule;
15+
private readonly Rule<ContentType, ConditionType> otherRule;
16+
17+
public OperatorContainsManyToOneTests()
18+
: base(testContentType)
19+
{
20+
this.expectedMatchRule = RuleBuilder.NewRule<ContentType, ConditionType>()
21+
.WithName("Expected rule")
22+
.WithDateBegin(UtcDate("2020-01-01Z"))
23+
.WithContent(testContentType, "Just as expected!")
24+
.WithCondition(ConditionType.ConditionType1, Operators.Contains, "Cat")
25+
.Build()
26+
.Rule;
27+
28+
this.otherRule = RuleBuilder.NewRule<ContentType, ConditionType>()
29+
.WithName("Other rule")
30+
.WithDateBegin(UtcDate("2020-01-01Z"))
31+
.WithContent(testContentType, "Oops! Not expected to be matched.")
32+
.Build()
33+
.Rule;
34+
35+
this.AddRules(this.CreateTestRules());
36+
}
37+
38+
[Theory]
39+
[InlineData(false)]
40+
[InlineData(true)]
41+
public async Task RulesEngine_GivenConditionType1WithArrayOfStringsContainingCat_MatchesExpectedRule(bool compiled)
42+
{
43+
// Arrange
44+
var emptyConditions = new[]
45+
{
46+
new Condition<ConditionType>(ConditionType.ConditionType1, new[]{ "Dog", "Fish", "Cat", "Spider", "Mockingbird", })
47+
};
48+
var matchDate = UtcDate("2020-01-02Z");
49+
50+
// Act
51+
var actualMatch = await this.MatchOneAsync(matchDate, emptyConditions, compiled);
52+
53+
// Assert
54+
actualMatch.Should().BeEquivalentTo(expectedMatchRule);
55+
}
56+
57+
[Theory]
58+
[InlineData(false)]
59+
[InlineData(true)]
60+
public async Task RulesEngine_GivenConditionType1WithArrayOfStringsNotContainingCat_MatchesOtherRule(bool compiled)
61+
{
62+
// Arrange
63+
var emptyConditions = new[]
64+
{
65+
new Condition<ConditionType>(ConditionType.ConditionType1, new[]{ "Dog", "Fish", "Bat", "Spider", "Mockingbird", })
66+
};
67+
var matchDate = UtcDate("2020-01-02Z");
68+
69+
// Act
70+
var actualMatch = await this.MatchOneAsync(matchDate, emptyConditions, compiled);
71+
72+
// Assert
73+
actualMatch.Should().BeEquivalentTo(otherRule);
74+
}
75+
76+
private IEnumerable<RuleSpecification> CreateTestRules()
77+
{
78+
var ruleSpecs = new List<RuleSpecification>
79+
{
80+
new RuleSpecification(expectedMatchRule, RuleAddPriorityOption.ByPriorityNumber(1)),
81+
new RuleSpecification(otherRule, RuleAddPriorityOption.ByPriorityNumber(2))
82+
};
83+
84+
return ruleSpecs;
85+
}
86+
}
87+
}

0 commit comments

Comments
 (0)