Skip to content

Commit 6eff220

Browse files
YogeshPrajYogesh Prajapati
andauthored
Batch-5: fix #668 STJ JsonElement unwrap, #676 docs cleanup, document #692 (#737)
for STJ, properties on an ExpandoObject built from JSON came through as JsonElement values. Rule expressions like `input1.country == "india"` then failed with "binary operator Equal is not defined for the types 'JsonElement' and 'System.String'." Utils.CreateAbstractClassType now infers the native CLR type from a JsonElement's ValueKind (string / int / long / double / bool / null), and Utils.CreateObject unwraps JsonElement scalars to their native values before assigning them. Objects and arrays inside the JsonElement keep JsonElement shape — that path is for typed Newtonsoft-style models that weren't using ExpandoObject anyway. in JSON examples across README.md, docs/Getting-Started.md, and docs/index.md. Removed. after 5.0.4." That's actually standard C# Nullable<T> semantics — both `null < x` and `null > x` are false. The pre-5.0.4 behavior was a Newtonsoft / Dynamic.Core quirk, not the documented contract. Added an explicit test documenting the current behavior plus the canonical `!Dt.HasValue || Dt < someDate` workaround for users who want null-aware ordering. Not changed here: #679 (rule-chaining via @RuleName) is already covered by in-flight PR #680 — no point duplicating that work. #710 and #709 are unrelated AGV-themed spam; closing comments are in issue-close-comments-batch-5.md. All 164 unit tests pass on net6 / net8 / net9 / net10. Co-authored-by: Yogesh Prajapati <yogeshcprajapati@outlook.com>
1 parent f491ca5 commit 6eff220

7 files changed

Lines changed: 207 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ All notable changes to this project will be documented in this file.
99
- New `ReSettings.AutoExecuteActions` (default `true`). Set to `false` to evaluate rules without automatically running their OnSuccess/OnFailure actions, so callers can run actions selectively via `ExecuteActionWorkflowAsync` (#596).
1010
- Documented and tested passing computed `additionalInputs` into the `EvaluateRule` action — the additionalInput `Name` is referenced directly in the target rule's expression (#573).
1111

12+
### Fixes
13+
- `Utils.CreateAbstractClassType` / `CreateObject` now unwrap `System.Text.Json.JsonElement` scalar values to their native CLR types (string / int / long / double / bool / null) when building typed objects from `ExpandoObject` inputs. This restores the pre-System.Text.Json behavior for rule expressions like `input1.country == "india"` that previously failed with "binary operator Equal is not defined for the types 'JsonElement' and 'String'" (#668).
14+
15+
### Docs
16+
- Removed the obsolete `ErrorType` field from JSON examples in `README.md`, `docs/Getting-Started.md`, and `docs/index.md`. `ErrorType` was removed from the `Rule` model in 4.0.0 (#676).
17+
18+
### Regression guards added (already correct on master, now covered by tests)
19+
- #692 — Nullable `DateTime` comparisons against `null` (`null < someDate` / `null > someDate`) return `false`, matching standard C# `Nullable<T>` semantics. Test documents the recommended `HasValue` workaround for users who want null-aware ordering.
20+
1221
## [6.0.1-preview.1]
1322

1423
### Performance

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,13 @@ An example rule:
3131
"RuleName": "GiveDiscount10",
3232
"SuccessEvent": "10",
3333
"ErrorMessage": "One or more adjust rules failed.",
34-
"ErrorType": "Error",
3534
"RuleExpressionType": "LambdaExpression",
3635
"Expression": "input1.country == \"india\" AND input1.loyaltyFactor <= 2 AND input1.totalPurchasesToDate >= 5000"
3736
},
3837
{
3938
"RuleName": "GiveDiscount20",
4039
"SuccessEvent": "20",
4140
"ErrorMessage": "One or more adjust rules failed.",
42-
"ErrorType": "Error",
4341
"RuleExpressionType": "LambdaExpression",
4442
"Expression": "input1.country == \"india\" AND input1.loyaltyFactor >= 3 AND input1.totalPurchasesToDate >= 10000"
4543
}

docs/Getting-Started.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,39 +19,34 @@ Rules schema is available in the [schema file](https://github.com/microsoft/Rule
1919
"RuleName": "GiveDiscount10",
2020
"SuccessEvent": "10",
2121
"ErrorMessage": "One or more adjust rules failed.",
22-
"ErrorType": "Error",
2322
"RuleExpressionType": "LambdaExpression",
2423
"Expression": "input1.country == \"india\" AND input1.loyalityFactor <= 2 AND input1.totalPurchasesToDate >= 5000 AND input2.totalOrders > 2 AND input3.noOfVisitsPerMonth > 2"
2524
},
2625
{
2726
"RuleName": "GiveDiscount20",
2827
"SuccessEvent": "20",
2928
"ErrorMessage": "One or more adjust rules failed.",
30-
"ErrorType": "Error",
3129
"RuleExpressionType": "LambdaExpression",
3230
"Expression": "input1.country == \"india\" AND input1.loyalityFactor == 3 AND input1.totalPurchasesToDate >= 10000 AND input2.totalOrders > 2 AND input3.noOfVisitsPerMonth > 2"
3331
},
3432
{
3533
"RuleName": "GiveDiscount25",
3634
"SuccessEvent": "25",
3735
"ErrorMessage": "One or more adjust rules failed.",
38-
"ErrorType": "Error",
3936
"RuleExpressionType": "LambdaExpression",
4037
"Expression": "input1.country != \"india\" AND input1.loyalityFactor >= 2 AND input1.totalPurchasesToDate >= 10000 AND input2.totalOrders > 2 AND input3.noOfVisitsPerMonth > 5"
4138
},
4239
{
4340
"RuleName": "GiveDiscount30",
4441
"SuccessEvent": "30",
4542
"ErrorMessage": "One or more adjust rules failed.",
46-
"ErrorType": "Error",
4743
"RuleExpressionType": "LambdaExpression",
4844
"Expression": "input1.loyalityFactor > 3 AND input1.totalPurchasesToDate >= 50000 AND input1.totalPurchasesToDate <= 100000 AND input2.totalOrders > 5 AND input3.noOfVisitsPerMonth > 15"
4945
},
5046
{
5147
"RuleName": "GiveDiscount35",
5248
"SuccessEvent": "35",
5349
"ErrorMessage": "One or more adjust rules failed.",
54-
"ErrorType": "Error",
5550
"RuleExpressionType": "LambdaExpression",
5651
"Expression": "input1.loyalityFactor > 3 AND input1.totalPurchasesToDate >= 100000 AND input2.totalOrders > 15 AND input3.noOfVisitsPerMonth > 25"
5752
}

docs/index.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,6 @@ Define OnSuccess or OnFailure Action for your Rule:
356356
"RuleName": "GiveDiscount10Percent",
357357
"SuccessEvent": "10",
358358
"ErrorMessage": "One or more adjust rules failed.",
359-
"ErrorType": "Error",
360359
"RuleExpressionType": "LambdaExpression",
361360
"Expression": "input1.couy == \"india\" AND input1.loyalityFactor <= 2 AND input1.totalPurchasesToDate >= 5000 AND input2.totalOrders > 2 AND input2.noOfVisitsPerMonth > 2",
362361
"Actions": {
@@ -414,7 +413,6 @@ Define OnSuccess or OnFailure Action for your Rule:
414413
"RuleName": "GiveDiscount10Percent",
415414
"SuccessEvent": "10",
416415
"ErrorMessage": "One or more adjust rules failed.",
417-
"ErrorType": "Error",
418416
"RuleExpressionType": "LambdaExpression",
419417
"Expression": "input1.couy == \"india\" AND input1.loyalityFactor <= 2 AND input1.totalPurchasesToDate >= 5000 AND input2.totalOrders > 2 AND input2.noOfVisitsPerMonth > 2",
420418
"Actions": {
@@ -515,7 +513,6 @@ Actions can have async code as well
515513
"RuleName": "GiveDiscount10Percent",
516514
"SuccessEvent": "10",
517515
"ErrorMessage": "One or more adjust rules failed.",
518-
"ErrorType": "Error",
519516
"RuleExpressionType": "LambdaExpression",
520517
"Expression": "input1.couy == \"india\" AND input1.loyalityFactor <= 2 AND input1.totalPurchasesToDate >= 5000 AND input2.totalOrders > 2 AND input2.noOfVisitsPerMonth > 2",
521518
"Actions": {

src/RulesEngine/HelperFunctions/Utils.cs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ public static Type CreateAbstractClassType(dynamic input)
3535

3636
if (input is System.Text.Json.JsonElement jsonElement)
3737
{
38-
if (jsonElement.ValueKind == System.Text.Json.JsonValueKind.Null)
39-
{
40-
return typeof(object);
41-
}
38+
// STJ leaves scalar properties as JsonElement values. Infer the native CLR type
39+
// so member comparisons in rule expressions (e.g. `input1.country == "india"`)
40+
// work without users seeing "binary operator Equal is not defined for the types
41+
// 'JsonElement' and 'String'." See #668.
42+
return InferJsonElementClrType(jsonElement);
4243
}
4344
else if (input == null)
4445
{
@@ -68,6 +69,43 @@ public static Type CreateAbstractClassType(dynamic input)
6869
return type;
6970
}
7071

72+
// Maps a JsonElement to the CLR type its scalar value would have once unwrapped.
73+
// Objects and arrays keep their JsonElement type — callers using objects/arrays in
74+
// expressions are already on a typed-path with Newtonsoft-style models. See #668.
75+
private static Type InferJsonElementClrType(System.Text.Json.JsonElement el)
76+
{
77+
switch (el.ValueKind)
78+
{
79+
case System.Text.Json.JsonValueKind.String: return typeof(string);
80+
case System.Text.Json.JsonValueKind.True:
81+
case System.Text.Json.JsonValueKind.False: return typeof(bool);
82+
case System.Text.Json.JsonValueKind.Number:
83+
if (el.TryGetInt32(out _)) return typeof(int);
84+
if (el.TryGetInt64(out _)) return typeof(long);
85+
return typeof(double);
86+
case System.Text.Json.JsonValueKind.Null:
87+
case System.Text.Json.JsonValueKind.Undefined: return typeof(object);
88+
default: return typeof(System.Text.Json.JsonElement);
89+
}
90+
}
91+
92+
private static object UnwrapJsonElementScalar(System.Text.Json.JsonElement el)
93+
{
94+
switch (el.ValueKind)
95+
{
96+
case System.Text.Json.JsonValueKind.String: return el.GetString();
97+
case System.Text.Json.JsonValueKind.True: return true;
98+
case System.Text.Json.JsonValueKind.False: return false;
99+
case System.Text.Json.JsonValueKind.Number:
100+
if (el.TryGetInt32(out var i)) return i;
101+
if (el.TryGetInt64(out var l)) return l;
102+
return el.GetDouble();
103+
case System.Text.Json.JsonValueKind.Null:
104+
case System.Text.Json.JsonValueKind.Undefined: return null;
105+
default: return el;
106+
}
107+
}
108+
71109
// Returns the CLR List<T> type that should represent a heterogeneous IList of ExpandoObject /
72110
// IDictionary<string, object> elements. Walks every element so properties that only appear in
73111
// later elements are still included in the generated type. See #704.
@@ -158,11 +196,20 @@ public static object CreateObject(Type type, dynamic input)
158196
};
159197
val = newList;
160198
}
199+
else if (expando.Value is System.Text.Json.JsonElement je)
200+
{
201+
// Unwrap scalar JsonElement to its native value so the typed property
202+
// receives a string/int/bool/etc. rather than a JsonElement. See #668.
203+
val = UnwrapJsonElementScalar(je);
204+
}
161205
else
162206
{
163207
val = expando.Value;
164208
}
165-
propInfo.SetValue(obj, val, null);
209+
if (val != null)
210+
{
211+
propInfo.SetValue(obj, val, null);
212+
}
166213
}
167214
}
168215

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using RulesEngine.Models;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Dynamic;
8+
using System.Text.Json;
9+
using System.Threading.Tasks;
10+
using Xunit;
11+
12+
namespace RulesEngine.UnitTest
13+
{
14+
[ExcludeFromCodeCoverage]
15+
public class Issue668Test
16+
{
17+
// Reporter scenario: JSON inputs deserialized via System.Text.Json end up as
18+
// JsonElement values inside an ExpandoObject. Rule expressions then compare
19+
// those JsonElements against strings/numbers and fail with:
20+
// The binary operator Equal is not defined for the types
21+
// 'System.Text.Json.JsonElement' and 'System.String'.
22+
//
23+
// The migration from Newtonsoft.Json to System.Text.Json (#599) is the cause —
24+
// Newtonsoft produced native .NET types into the ExpandoObject; STJ produces
25+
// JsonElement.
26+
[Fact]
27+
public async Task ExpandoObject_WithJsonElementProperty_ComparedToString()
28+
{
29+
// Mirrors what STJ does when deserializing a JSON object into an ExpandoObject
30+
// via a JsonDocument: each property becomes a JsonElement.
31+
var json = "{\"country\":\"india\",\"loyaltyFactor\":2}";
32+
using var doc = JsonDocument.Parse(json);
33+
34+
// Build an ExpandoObject with JsonElement values, the way #599 would.
35+
IDictionary<string, object> expando = new ExpandoObject();
36+
foreach (var prop in doc.RootElement.EnumerateObject())
37+
{
38+
expando[prop.Name] = prop.Value.Clone(); // JsonElement, NOT a string
39+
}
40+
41+
var workflow = new Workflow
42+
{
43+
WorkflowName = "Discount",
44+
Rules = new[] {
45+
new Rule { RuleName = "R", Expression = "input1.country == \"india\"" }
46+
}
47+
};
48+
var engine = new RulesEngine(new[] { workflow });
49+
var results = await engine.ExecuteAllRulesAsync(
50+
"Discount", new[] { RuleParameter.Create("input1", (ExpandoObject)expando) });
51+
52+
Assert.True(results[0].IsSuccess,
53+
$"Expected success. Got ExceptionMessage = {results[0].ExceptionMessage}");
54+
}
55+
}
56+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using RulesEngine.Models;
5+
using System;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Threading.Tasks;
8+
using Xunit;
9+
10+
namespace RulesEngine.UnitTest
11+
{
12+
[ExcludeFromCodeCoverage]
13+
public class Issue692Test
14+
{
15+
public class WithNullableDate
16+
{
17+
public DateTime? Dt { get; set; }
18+
}
19+
20+
// The reporter says: when comparing a null DateTime against a set DateTime, BOTH
21+
// `<` and `>` return false (whereas in 5.0.3 and earlier null was treated as
22+
// "less than" any set datetime).
23+
//
24+
// Behavior of standard .NET nullable DateTime semantics:
25+
// null < someDateTime → false (Nullable<T> comparisons return false when either operand is null)
26+
// null > someDateTime → false
27+
// This is the SAME as standard C# semantics — Nullable<T> comparisons are tri-valued
28+
// and false-when-null is the documented behavior. There is no "null is less than" rule
29+
// in .NET; the reporter's previous behavior was either via Newtonsoft.Json string-typing
30+
// (null treated as empty / default DateTime) or via a Dynamic.Core quirk.
31+
//
32+
// These tests document the current behavior so we don't accidentally regress.
33+
[Fact]
34+
public async Task NullableDateTime_LessThan_NullReturnsFalse()
35+
{
36+
var workflow = new Workflow
37+
{
38+
WorkflowName = "wf",
39+
Rules = new[] { new Rule { RuleName = "R", Expression = "input1.Dt < DateTime.Now" } }
40+
};
41+
var engine = new RulesEngine(new[] { workflow },
42+
new ReSettings { CustomTypes = new[] { typeof(DateTime) } });
43+
var results = await engine.ExecuteAllRulesAsync(
44+
"wf", new[] { RuleParameter.Create("input1", new WithNullableDate { Dt = null }) });
45+
46+
Assert.False(results[0].IsSuccess);
47+
}
48+
49+
[Fact]
50+
public async Task NullableDateTime_GreaterThan_NullReturnsFalse()
51+
{
52+
var workflow = new Workflow
53+
{
54+
WorkflowName = "wf",
55+
Rules = new[] { new Rule { RuleName = "R", Expression = "input1.Dt > DateTime.Now" } }
56+
};
57+
var engine = new RulesEngine(new[] { workflow },
58+
new ReSettings { CustomTypes = new[] { typeof(DateTime) } });
59+
var results = await engine.ExecuteAllRulesAsync(
60+
"wf", new[] { RuleParameter.Create("input1", new WithNullableDate { Dt = null }) });
61+
62+
Assert.False(results[0].IsSuccess);
63+
}
64+
65+
// The canonical workaround for users who DO want null-aware semantics: check HasValue
66+
// explicitly. This is also the standard C# pattern.
67+
[Fact]
68+
public async Task NullableDateTime_ExplicitHasValueCheck_WorksAsExpected()
69+
{
70+
var workflow = new Workflow
71+
{
72+
WorkflowName = "wf",
73+
Rules = new[] {
74+
new Rule { RuleName = "TreatNullAsLess",
75+
Expression = "!input1.Dt.HasValue || input1.Dt < DateTime.Now" }
76+
}
77+
};
78+
var engine = new RulesEngine(new[] { workflow },
79+
new ReSettings { CustomTypes = new[] { typeof(DateTime) } });
80+
81+
var withNull = await engine.ExecuteAllRulesAsync(
82+
"wf", new[] { RuleParameter.Create("input1", new WithNullableDate { Dt = null }) });
83+
Assert.True(withNull[0].IsSuccess);
84+
85+
var withValue = await engine.ExecuteAllRulesAsync(
86+
"wf", new[] { RuleParameter.Create("input1", new WithNullableDate { Dt = DateTime.Now.AddDays(-1) }) });
87+
Assert.True(withValue[0].IsSuccess);
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)