Skip to content

Commit 29913a6

Browse files
YogeshPrajYogesh Prajapati
andauthored
Fix #624, #704, #711, #714, #717: action exception propagation, list schema merging, OutputExpression error hint, global-param dedup, object-return diagnostics (#728)
* Fix #717, #711, #714, #704, #624: bug bash batch #624: ActionBase.ExecuteAndReturnResultAsync silently captured every exception into result.Exception regardless of EnableExceptionAsErrorMessage, so an action throwing inside Run() never propagated when the setting was false. Add ThrowIfActionExceptionShouldPropagate in RulesEngine to honor the contract documented on ReSettings. #711: OutputExpressionAction emitted a cryptic "Expression is missing an 'as' clause" when users wrote C#-style anonymous objects (`new { X = ... } as Result`). Detect the pattern and wrap the parse exception with a clear hint pointing at the Dynamic.Core syntax (`new (value as Name, ...)`). #714: Global params were re-evaluated for every rule in the workflow, so `Utils.FromDb(myInput)` ran N times per ExecuteAllRulesAsync call. Move the global-params compilation+evaluation to workflow scope: compile a single delegate at RegisterRule time, store it in RulesCache, evaluate once in ExecuteAllRuleByWorkflow and append the result as RuleParameters to each compiled rule. Preserve the existing per-rule error messages when global compilation or evaluation fails. ExecuteActionWorkflowAsync (which bypasses RulesCache) evaluates globals ad-hoc. #704: Utils.CreateAbstractClassType used only list[0] when generating the CLR type for a heterogeneous IList of ExpandoObject/Dictionary, so any property appearing only in later elements was dropped. Walk every element and union the schema; recursively merge nested dictionaries. #717: Methods declared to return `object` cannot have their result members accessed in Dynamic.Core expressions — the parser errors with "exists in type 'Object'" or "is not defined for the types 'System.Object'". We can't unbox at parse time, but the error message gave no clue what was wrong. Detect those patterns in LambdaExpressionBuilder and append a hint to change the method's return type to the concrete class. All 144 tests pass on net6.0 / net8.0 / net9.0 / net10.0. * Deduplicate helpers introduced in the batch fix - RulesEngine.cs: ApplyGlobalParams and EvaluateGlobalsAdHoc both built RuleParameters from a globals delegate and concatenated them with the user's inputs. Extracted AppendGlobals (delegate-invoke + concat tail) and CompileGlobalParamsDelegate (GetRuleExpressionParameters + CompileScopedParams). - Utils.cs: MergeListElementSchemas and MergeTwoDictionaries shared the same pair-merge logic. Replaced both with MergeDictionaries (n-ary fold) + MergeValues (handles dict/dict, list/list, and first-non-null fallback). The latter also makes nested list-concatenation consistent across all call sites. No behavior change. All 144 tests still pass on net6/8/9/10. --------- Co-authored-by: Yogesh Prajapati <yogeshcprajapati@outlook.com>
1 parent 6e62a1b commit 29913a6

11 files changed

Lines changed: 773 additions & 37 deletions

File tree

src/RulesEngine/Actions/ExpressionOutputAction.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33

44
using RulesEngine.ExpressionBuilders;
55
using RulesEngine.Models;
6+
using System;
7+
using System.Linq.Dynamic.Core.Exceptions;
8+
using System.Text.RegularExpressions;
69
using System.Threading.Tasks;
710

811
namespace RulesEngine.Actions
912
{
1013
public class OutputExpressionAction : ActionBase
1114
{
15+
private static readonly Regex CSharpAnonymousObjectPattern =
16+
new Regex(@"\bnew\s*\{", RegexOptions.Compiled);
17+
1218
private readonly RuleExpressionParser _ruleExpressionParser;
1319

1420
public OutputExpressionAction(RuleExpressionParser ruleExpressionParser)
@@ -19,7 +25,19 @@ public OutputExpressionAction(RuleExpressionParser ruleExpressionParser)
1925
public override ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
2026
{
2127
var expression = context.GetContext<string>("expression");
22-
return new ValueTask<object>(_ruleExpressionParser.Evaluate<object>(expression, ruleParameters));
28+
try
29+
{
30+
return new ValueTask<object>(_ruleExpressionParser.Evaluate<object>(expression, ruleParameters));
31+
}
32+
catch (ParseException ex) when (CSharpAnonymousObjectPattern.IsMatch(expression ?? string.Empty))
33+
{
34+
throw new ParseException(
35+
"OutputExpression failed to parse. It looks like the expression uses C#-style anonymous-object syntax " +
36+
"(`new { Name = value, ... }`), which is not supported by System.Linq.Dynamic.Core. " +
37+
"Use the Dynamic.Core form instead: `new (value as Name, ...)` — parentheses, and each field needs an `as Alias`. " +
38+
"Original parser error: " + ex.Message,
39+
ex.Position);
40+
}
2341
}
2442
}
2543
}

src/RulesEngine/ExpressionBuilders/LambdaExpressionBuilder.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,24 @@ internal override RuleFunc<RuleResultTree> BuildDelegateForRule(Rule rule, RuleP
3434
{
3535
Helpers.HandleRuleException(ex,rule,_reSettings);
3636

37-
var exceptionMessage = Helpers.GetExceptionMessage($"Exception while parsing expression `{rule?.Expression}` - {ex.Message}",
37+
var detail = ex.Message;
38+
if (detail != null
39+
&& (detail.Contains("exists in type 'Object'")
40+
|| detail.Contains("'System.Object'"))
41+
&& (rule?.Expression?.Contains('(') == true))
42+
{
43+
// Dynamic.Core can only resolve members and operators against a static return type.
44+
// If a custom/static method's declared return type is `object`, member access or
45+
// operator usage on its result fails. See #717.
46+
detail += " (Hint: a method called in this expression appears to have an `object` return type. " +
47+
"Change its return type to the concrete class — Dynamic.Core cannot resolve members or operators on `object`.)";
48+
}
49+
50+
var exceptionMessage = Helpers.GetExceptionMessage($"Exception while parsing expression `{rule?.Expression}` - {detail}",
3851
_reSettings);
3952

4053
bool func(object[] param) => false;
41-
54+
4255
return Helpers.ToResultTree(_reSettings, rule, null,func, exceptionMessage);
4356
}
4457
}

src/RulesEngine/HelperFunctions/Utils.cs

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,7 @@ public static Type CreateAbstractClassType(dynamic input)
5555
Type value;
5656
if (expando.Value is IList list)
5757
{
58-
if (list.Count == 0)
59-
{
60-
value = typeof(List<object>);
61-
}
62-
else
63-
{
64-
var internalType = CreateAbstractClassType(list[0]);
65-
value = new List<object>().Cast(internalType).ToList(internalType).GetType();
66-
}
67-
58+
value = BuildListType(list);
6859
}
6960
else
7061
{
@@ -77,6 +68,63 @@ public static Type CreateAbstractClassType(dynamic input)
7768
return type;
7869
}
7970

71+
// Returns the CLR List<T> type that should represent a heterogeneous IList of ExpandoObject /
72+
// IDictionary<string, object> elements. Walks every element so properties that only appear in
73+
// later elements are still included in the generated type. See #704.
74+
private static Type BuildListType(IList list)
75+
{
76+
if (list.Count == 0)
77+
{
78+
return typeof(List<object>);
79+
}
80+
81+
var firstElement = list[0];
82+
if (firstElement is ExpandoObject || firstElement is IDictionary<string, object>)
83+
{
84+
var merged = MergeDictionaries(list.OfType<IDictionary<string, object>>());
85+
var internalType = CreateAbstractClassTypeFromDictionary(merged);
86+
return new List<object>().Cast(internalType).ToList(internalType).GetType();
87+
}
88+
89+
// Non-schema-like element: fall back to first-element type as before.
90+
var legacyType = CreateAbstractClassType(firstElement);
91+
return new List<object>().Cast(legacyType).ToList(legacyType).GetType();
92+
}
93+
94+
// Unions schemas from any number of dict-like inputs. Used both to merge sibling
95+
// elements of a heterogeneous list (#704) and to merge nested dicts recursively.
96+
private static IDictionary<string, object> MergeDictionaries(IEnumerable<IDictionary<string, object>> dictionaries)
97+
{
98+
var merged = new Dictionary<string, object>();
99+
foreach (var dict in dictionaries)
100+
{
101+
foreach (var kvp in dict)
102+
{
103+
merged[kvp.Key] = merged.TryGetValue(kvp.Key, out var existing)
104+
? MergeValues(existing, kvp.Value)
105+
: kvp.Value;
106+
}
107+
}
108+
return merged;
109+
}
110+
111+
private static object MergeValues(object existing, object incoming)
112+
{
113+
if (existing is IDictionary<string, object> a && incoming is IDictionary<string, object> b)
114+
{
115+
return MergeDictionaries(new[] { a, b });
116+
}
117+
if (existing is IList la && incoming is IList lb)
118+
{
119+
var combined = new List<object>();
120+
foreach (var e in la) combined.Add(e);
121+
foreach (var e in lb) combined.Add(e);
122+
return combined;
123+
}
124+
// First non-null wins on type conflict.
125+
return existing ?? incoming;
126+
}
127+
80128
public static object CreateObject(Type type, dynamic input)
81129
{
82130
if (input is not ExpandoObject expandoObject)
@@ -152,17 +200,7 @@ private static Type CreateAbstractClassTypeFromDictionary(IDictionary<string, ob
152200
}
153201
else if (kvp.Value is IList list)
154202
{
155-
if (list.Count == 0)
156-
{
157-
valueType = typeof(List<object>);
158-
}
159-
else
160-
{
161-
var internalType = list[0] is IDictionary<string, object> innerDict
162-
? CreateAbstractClassTypeFromDictionary(innerDict)
163-
: (list[0] is ExpandoObject ? CreateAbstractClassType(list[0]) : list[0]?.GetType() ?? typeof(object));
164-
valueType = new List<object>().Cast(internalType).ToList(internalType).GetType();
165-
}
203+
valueType = BuildListType(list);
166204
}
167205
else
168206
{

src/RulesEngine/RuleCompiler.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,10 @@ internal RuleFunc<RuleResultTree> CompileRule(Rule rule, RuleExpressionType rule
5959
var globalParamExp = globalParams.Value;
6060
var extendedRuleParams = ruleParams.Concat(globalParamExp.Select(c => new RuleParameter(c.ParameterExpression.Name,c.ParameterExpression.Type)))
6161
.ToArray();
62-
var ruleExpression = GetDelegateForRule(rule, extendedRuleParams);
63-
64-
65-
return GetWrappedRuleFunc(rule,ruleExpression,ruleParams,globalParamExp);
62+
// Note: globals are no longer evaluated here per rule. The caller is expected
63+
// to evaluate workflow-level globals once and pass them as extra RuleParameters
64+
// when invoking the returned delegate. See #714.
65+
return GetDelegateForRule(rule, extendedRuleParams);
6666
}
6767
catch (Exception ex)
6868
{

src/RulesEngine/RulesCache.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,29 @@ internal class RulesCache
1616
/// <summary>The compile rules</summary>
1717
private readonly MemCache _compileRules;
1818

19+
/// <summary>Per-workflow compiled delegate that evaluates all global params once.</summary>
20+
private readonly MemCache _compiledGlobalParams;
21+
1922
/// <summary>The workflow rules</summary>
2023
private readonly ConcurrentDictionary<string, (Workflow, long)> _workflow = new ConcurrentDictionary<string, (Workflow, long)>();
2124

2225

2326
public RulesCache(ReSettings reSettings)
2427
{
2528
_compileRules = new MemCache(reSettings.CacheConfig);
29+
_compiledGlobalParams = new MemCache(reSettings.CacheConfig);
30+
}
31+
32+
/// <summary>Adds or updates the workflow-level global-params delegate.</summary>
33+
public void AddOrUpdateGlobalParamsDelegate(string compiledRuleKey, Func<object[], Dictionary<string, object>> globalParamsDelegate)
34+
{
35+
_compiledGlobalParams.Set(compiledRuleKey, globalParamsDelegate);
36+
}
37+
38+
/// <summary>Gets the workflow-level global-params delegate, or null if the workflow has no globals.</summary>
39+
public Func<object[], Dictionary<string, object>> GetGlobalParamsDelegate(string compiledRuleKey)
40+
{
41+
return _compiledGlobalParams.Get<Func<object[], Dictionary<string, object>>>(compiledRuleKey);
2642
}
2743

2844

@@ -81,6 +97,7 @@ public void Clear()
8197
{
8298
_workflow.Clear();
8399
_compileRules.Clear();
100+
_compiledGlobalParams.Clear();
84101
}
85102

86103
/// <summary>Gets the work flow rules.</summary>
@@ -133,10 +150,11 @@ public void Remove(string workflowName)
133150
{
134151
if (_workflow.TryRemove(workflowName, out var workflowObj))
135152
{
136-
var compiledKeysToRemove = _compileRules.GetKeys().Where(key => key.StartsWith(workflowName));
153+
var compiledKeysToRemove = _compileRules.GetKeys().Where(key => key.StartsWith(workflowName)).ToList();
137154
foreach (var key in compiledKeysToRemove)
138155
{
139156
_compileRules.Remove(key);
157+
_compiledGlobalParams.Remove(key);
140158
}
141159
}
142160
}

0 commit comments

Comments
 (0)