Skip to content
1 change: 1 addition & 0 deletions RulesEngine.sln
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
CHANGELOG.md = CHANGELOG.md
global.json = global.json
LICENSE = LICENSE
README.md = README.md
schema\workflow-list-schema.json = schema\workflow-list-schema.json
schema\workflow-schema.json = schema\workflow-schema.json
Expand Down
29 changes: 10 additions & 19 deletions src/RulesEngine/Actions/ActionContext.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json;
using RulesEngine.Models;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using static FastExpressionCompiler.ImTools.SmallMap;

namespace RulesEngine.Actions
{
Expand All @@ -25,25 +26,16 @@ public ActionContext(IDictionary<string, object> context, RuleResultTree parentR
_context = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in context)
{
string key = kv.Key;
string value;
switch (kv.Value.GetType().Name)
{
case "String":
case "JsonElement":
value = kv.Value.ToString();
break;
default:
value = JsonSerializer.Serialize(kv.Value);
break;

}
_context.Add(key, value);
if (kv.Value == null)
continue;
else if (kv.Value is string || kv.Value is JsonElement)
_context.Add(kv.Key, kv.Value.ToString());
else
_context.Add(kv.Key, JsonSerializer.Serialize(kv.Value));
}
_parentResult = parentResult;
}


public RuleResultTree GetParentRuleResult()
{
return _parentResult;
Expand All @@ -68,9 +60,8 @@ public T GetContext<T>(string name)
try
{
if (typeof(T) == typeof(string))
{
return (T)Convert.ChangeType(_context[name], typeof(T));
}
return (T)(object)_context[name];

return JsonSerializer.Deserialize<T>(_context[name]);
}
catch (KeyNotFoundException)
Expand Down
14 changes: 13 additions & 1 deletion src/RulesEngine/CustomTypeProvider.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using RulesEngine.HelperFunctions;
Expand All @@ -9,9 +9,17 @@

namespace RulesEngine
{
/// <summary>
/// Provides custom types to System.Linq.Dynamic.Core for use in dynamic rule expressions.
/// </summary>
public class CustomTypeProvider : DefaultDynamicLinqCustomTypeProvider
{
private HashSet<Type> _types;

/// <summary>
/// Initializes a new instance of the <see cref="CustomTypeProvider"/> class.
/// </summary>
/// <param name="types">An array of custom types to make available in dynamic expressions.</param>
public CustomTypeProvider(Type[] types) : base(ParsingConfig.Default)
{
_types = new HashSet<Type>(types ?? new Type[] { }) {
Expand All @@ -20,6 +28,10 @@ public CustomTypeProvider(Type[] types) : base(ParsingConfig.Default)
};
}

/// <summary>
/// Gets the set of custom types available in dynamic expressions.
/// </summary>
/// <returns>A hash set of custom types.</returns>
public override HashSet<Type> GetCustomTypes()
{
return _types;
Expand Down
16 changes: 4 additions & 12 deletions src/RulesEngine/ExpressionBuilders/RuleExpressionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,13 @@ namespace RulesEngine.ExpressionBuilders
public class RuleExpressionParser
{
private readonly ReSettings _reSettings;
private readonly IDictionary<string, MethodInfo> _methodInfo;
private static readonly MethodInfo _dictAddMethod = typeof(Dictionary<string, object>).GetMethod("Add", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(string), typeof(object) }, null);

public RuleExpressionParser(ReSettings reSettings = null)
{
_reSettings = reSettings ?? new ReSettings();
_methodInfo = new Dictionary<string, MethodInfo>();
PopulateMethodInfo();
}

private void PopulateMethodInfo()
{
var dict_add = typeof(Dictionary<string, object>).GetMethod("Add", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(string), typeof(object) }, null);
_methodInfo.Add("dict_add", dict_add);
}

public Expression Parse(string expression, ParameterExpression[] parameters, Type returnType)
{
if (parameters == null)
Expand Down Expand Up @@ -142,8 +135,7 @@ private Expression<Func<object[],Dictionary<string,object>>> CreateDictionaryExp
body.AddRange(variableExpressions);

var dict = Expression.Variable(typeof(Dictionary<string, object>));
var add = _methodInfo["dict_add"];


body.Add(Expression.Assign(dict, Expression.New(typeof(Dictionary<string, object>))));
variableExp.Add(dict);

Expand All @@ -156,7 +148,7 @@ private Expression<Func<object[],Dictionary<string,object>>> CreateDictionaryExp
var key = Expression.Constant(ruleExpParams[i].ParameterExpression.Name);
var value = Expression.Convert(ruleExpParams[i].ParameterExpression, typeof(object));
variableExp.Add(ruleExpParams[i].ParameterExpression);
body.Add(Expression.Call(dict, add, key, value));
body.Add(Expression.Call(dict, _dictAddMethod, key, value));

}
// Return value
Expand Down
120 changes: 120 additions & 0 deletions src/RulesEngine/HelperFunctions/ErrorMessageFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using RulesEngine.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace RulesEngine.HelperFunctions
{
/// <summary>
/// Formats error messages for rule results by replacing parameter placeholders with actual values.
/// </summary>
internal class ErrorMessageFormatter
{
/// <summary>
/// Regex pattern to match parameter placeholders in the format $(ParameterName).
/// </summary>
private const string ParamParseRegex = @"\$\(([^)]+)\)";

private readonly ReSettings _reSettings;

/// <summary>
/// Initializes a new instance of the <see cref="ErrorMessageFormatter"/> class.
/// </summary>
/// <param name="reSettings">The rules engine settings.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="reSettings"/> is null.</exception>
public ErrorMessageFormatter(ReSettings reSettings)
{
_reSettings = reSettings ?? throw new ArgumentNullException(nameof(reSettings));
}

/// <summary>
/// Formats error messages for the specified rule results by replacing parameter placeholders with actual values.
/// </summary>
/// <param name="ruleResultList">The collection of rule results to format.</param>
public void FormatErrorMessages(IEnumerable<RuleResultTree> ruleResultList)
{
if (!_reSettings.EnableFormattedErrorMessage)
{
return;
}

foreach (var ruleResult in ruleResultList?.Where(r => !r.IsSuccess))
{
var errorMessage = ruleResult?.Rule?.ErrorMessage;
if (string.IsNullOrWhiteSpace(ruleResult.ExceptionMessage) && errorMessage != null)
{
ruleResult.ExceptionMessage = BuildErrorMessage(errorMessage, ruleResult.Inputs);
}
}
}

/// <summary>
/// Builds an error message by replacing parameter placeholders with values from the inputs.
/// </summary>
/// <param name="errorMessage">The error message template containing placeholders.</param>
/// <param name="inputs">The input values to substitute into the placeholders.</param>
/// <returns>The formatted error message with placeholders replaced by actual values.</returns>
private static string BuildErrorMessage(string errorMessage, IDictionary<string, object> inputs)
{
var errorParameters = Regex.Matches(errorMessage, ParamParseRegex);

foreach (var param in errorParameters)
{
var paramVal = param?.ToString();
var property = paramVal?.Substring(2, paramVal.Length - 3);

if (string.IsNullOrEmpty(property))
{
continue;
}

if (property.Split('.').Length > 1)
{
var parts = property.Split(new[] { '.' }, 2);
var typeName = parts[0];
var propertyName = parts[1];
errorMessage = UpdateErrorMessage(errorMessage, inputs, property, typeName, propertyName);
}
else
{
var model = inputs?.FirstOrDefault(c => string.Equals(c.Key, property)).Value;
var value = model != null ? JsonSerializer.Serialize(model) : null;
errorMessage = errorMessage.Replace($"$({property})", value ?? $"$({property})");
}
}

return errorMessage;
}

/// <summary>
/// Updates an error message by replacing a specific parameter placeholder with a JSON property value.
/// </summary>
/// <param name="errorMessage">The error message template.</param>
/// <param name="inputs">The input values dictionary.</param>
/// <param name="property">The full property placeholder (e.g., "TypeName.PropertyName").</param>
/// <param name="typeName">The type name to look up in inputs.</param>
/// <param name="propertyName">The JSON property name to extract.</param>
/// <returns>The updated error message with the placeholder replaced.</returns>
private static string UpdateErrorMessage(string errorMessage, IDictionary<string, object> inputs, string property, string typeName, string propertyName)
{
var model = inputs?.FirstOrDefault(c => string.Equals(c.Key, typeName)).Value;

if (model != null)
{
using (var jDoc = JsonSerializer.SerializeToDocument(model))
{
errorMessage = jDoc.RootElement.TryGetProperty(propertyName, out var jElement) ?
errorMessage.Replace($"$({property})", jElement.GetRawText() ?? $"({property})") :
errorMessage.Replace($"$({property})", $"({property})");
}
}

return errorMessage;
}
}
}
29 changes: 20 additions & 9 deletions src/RulesEngine/HelperFunctions/MemCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,31 @@ public T Set<T>(string key, T value, DateTimeOffset? expiry = null)
{
var fixedExpiry = expiry ?? DateTimeOffset.MaxValue;

// If at capacity, evict oldest by expiry
while (_cacheDictionary.Count > _config.SizeLimit)
// If over capacity, scan once and remove expired or arbitrary
if (_cacheDictionary.Count >= _config.SizeLimit)
{
var oldest = _cacheDictionary.OrderBy(kv => kv.Value.expiry).FirstOrDefault();
if (oldest.Key != null)
foreach (var kv in _cacheDictionary)
{
_cacheDictionary.TryRemove(oldest.Key, out _);
}
else
{
break; // Shouldn't happen but prevents infinite loop
if (_cacheDictionary.Count < _config.SizeLimit)
break;

if (kv.Value.expiry < DateTimeOffset.UtcNow)
{
_cacheDictionary.TryRemove(kv.Key, out _);
}
}
}

// If still at capacity, remove arbitrary entries
while (_cacheDictionary.Count > _config.SizeLimit)
{
var keyToRemove = _cacheDictionary.Keys.FirstOrDefault();
if (keyToRemove == null)
break;

_cacheDictionary.TryRemove(keyToRemove, out _);
}

_cacheDictionary.AddOrUpdate(key, (value, fixedExpiry), (k, v) => (value, fixedExpiry));
return value;
}
Expand Down
Loading
Loading