From 5c6c519eed6fe3d906753c07e669d77047d36fb1 Mon Sep 17 00:00:00 2001 From: Yogesh Prajapati Date: Fri, 11 Sep 2026 17:01:35 +0100 Subject: [PATCH] Make assembly scanning optional and fix AutoRegisterInputType reallocation (#749) Add ReSettings.EnableAssemblyScanning (default true) to opt out of the AppDomain-wide [DynamicLinqType] scan in CustomTypeProvider. Stop reassigning ReSettings.CustomTypes on every workflow registration when no new types are discovered, which previously broke the ReferenceEquals cache in RuleExpressionParser and forced a fresh scan each time. --- CHANGELOG.md | 6 + src/RulesEngine/CustomTypeProvider.cs | 12 +- .../RuleExpressionParser.cs | 4 +- src/RulesEngine/Models/ReSettings.cs | 10 ++ src/RulesEngine/RulesEngine.cs | 10 +- test/RulesEngine.UnitTest/Issue749Test.cs | 130 ++++++++++++++++++ 6 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 test/RulesEngine.UnitTest/Issue749Test.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f940de..955bb7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Features +- New `ReSettings.EnableAssemblyScanning` (default `true`). Set to `false` to opt out of scanning every AppDomain assembly for `[DynamicLinqType]`-marked types during rule expression parsing. When disabled, only the explicitly supplied `CustomTypes` (plus auto-registered input types) are available in expressions, avoiding the scan cost. Preserves existing behavior by default (#749). + +### Fixes +- `AutoRegisterInputType` no longer replaces `ReSettings.CustomTypes` with a new array on every workflow registration when no new types were discovered. The unconditional reassignment broke the `ReferenceEquals` cache in `RuleExpressionParser`, forcing a fresh `CustomTypeProvider` (and assembly scan) each time workflows were added (#749). + ## [6.0.1] Stable release rolling up everything from `6.0.1-preview.1` through `6.0.1-preview.3` — no additional code changes beyond preview.3. See per-preview sections below for the full delta from `6.0.0`. diff --git a/src/RulesEngine/CustomTypeProvider.cs b/src/RulesEngine/CustomTypeProvider.cs index 81ff9ce7..9a784dd5 100644 --- a/src/RulesEngine/CustomTypeProvider.cs +++ b/src/RulesEngine/CustomTypeProvider.cs @@ -14,9 +14,15 @@ namespace RulesEngine public class CustomTypeProvider : DefaultDynamicLinqCustomTypeProvider { private readonly HashSet _types; + private readonly bool _enableAssemblyScanning; - public CustomTypeProvider(Type[] types) : base(ParsingConfig.Default) + public CustomTypeProvider(Type[] types) : this(types, true) { + } + + public CustomTypeProvider(Type[] types, bool enableAssemblyScanning) : base(ParsingConfig.Default) + { + _enableAssemblyScanning = enableAssemblyScanning; _types = new HashSet(types ?? Array.Empty()); _types.Add(typeof(ExpressionUtils)); @@ -50,7 +56,9 @@ public override HashSet GetCustomTypes() // The provider's type set is fixed after construction, so merge exactly once. if (_mergedTypes == null) { - var all = new HashSet(base.GetCustomTypes()); + var all = _enableAssemblyScanning + ? new HashSet(base.GetCustomTypes()) + : new HashSet(); all.UnionWith(_types); _mergedTypes = all; } diff --git a/src/RulesEngine/ExpressionBuilders/RuleExpressionParser.cs b/src/RulesEngine/ExpressionBuilders/RuleExpressionParser.cs index 1c25c167..d9b05acb 100644 --- a/src/RulesEngine/ExpressionBuilders/RuleExpressionParser.cs +++ b/src/RulesEngine/ExpressionBuilders/RuleExpressionParser.cs @@ -42,7 +42,7 @@ private static string BuildSettingsFingerprint(ReSettings settings) var customTypesKey = settings.CustomTypes == null ? "" : string.Join(",", settings.CustomTypes.Where(t => t != null).Select(t => t.AssemblyQualifiedName)); - return $"cs={settings.IsExpressionCaseSensitive};fast={settings.UseFastExpressionCompiler};types={customTypesKey}"; + return $"cs={settings.IsExpressionCaseSensitive};fast={settings.UseFastExpressionCompiler};scan={settings.EnableAssemblyScanning};types={customTypesKey}"; } private string BuildCompileCacheKey(string expression, RuleParameter[] ruleParams, Type returnType) @@ -98,7 +98,7 @@ private ParsingConfig GetParsingConfig() if (config == null || !ReferenceEquals(_cachedParsingConfigCustomTypes, customTypes)) { config = new ParsingConfig { - CustomTypeProvider = new CustomTypeProvider(customTypes), + CustomTypeProvider = new CustomTypeProvider(customTypes, _reSettings.EnableAssemblyScanning), IsCaseSensitive = _reSettings.IsExpressionCaseSensitive }; _cachedParsingConfigCustomTypes = customTypes; diff --git a/src/RulesEngine/Models/ReSettings.cs b/src/RulesEngine/Models/ReSettings.cs index db873598..0e3029ad 100644 --- a/src/RulesEngine/Models/ReSettings.cs +++ b/src/RulesEngine/Models/ReSettings.cs @@ -27,6 +27,7 @@ internal ReSettings(ReSettings reSettings) CacheConfig = reSettings.CacheConfig; IsExpressionCaseSensitive = reSettings.IsExpressionCaseSensitive; AutoRegisterInputType = reSettings.AutoRegisterInputType; + EnableAssemblyScanning = reSettings.EnableAssemblyScanning; UseFastExpressionCompiler = reSettings.UseFastExpressionCompiler; EnableExceptionAsErrorMessageForRuleExpressionParsing = reSettings.EnableExceptionAsErrorMessageForRuleExpressionParsing; AutoExecuteActions = reSettings.AutoExecuteActions; @@ -78,6 +79,15 @@ internal ReSettings(ReSettings reSettings) /// public bool AutoRegisterInputType { get; set; } = true; + /// + /// When true (default), the CustomTypeProvider scans all assemblies loaded in the + /// AppDomain for types decorated with [DynamicLinqType] so they can be used in rule + /// expressions. Set to false to opt out of this scan entirely (only the explicitly + /// supplied CustomTypes and input types are registered), which avoids the scanning + /// cost on workflow registration. See #749. + /// + public bool EnableAssemblyScanning { get; set; } = true; + /// /// Sets the mode for Nested rule execution, Default: All /// diff --git a/src/RulesEngine/RulesEngine.cs b/src/RulesEngine/RulesEngine.cs index 27bf8c08..b31bb88c 100644 --- a/src/RulesEngine/RulesEngine.cs +++ b/src/RulesEngine/RulesEngine.cs @@ -378,13 +378,21 @@ private bool RegisterRule(string workflowName, params RuleParameter[] ruleParams if (_reSettings.AutoRegisterInputType) { var collector = new HashSet(_reSettings.CustomTypes.Safe()); + var originalCount = collector.Count; foreach (var rp in ruleParams) { CollectAllElementTypes(rp.Type, collector); } - _reSettings.CustomTypes = collector.ToArray(); + // Only swap the array when new types were actually discovered. Reassigning it + // unconditionally replaces the reference on every registration, which breaks the + // ReferenceEquals-based cache in RuleExpressionParser and forces a fresh assembly + // scan each time workflows are added. See #749. + if (collector.Count != originalCount) + { + _reSettings.CustomTypes = collector.ToArray(); + } } // Compile global params ONCE per workflow registration. The resulting delegate is diff --git a/test/RulesEngine.UnitTest/Issue749Test.cs b/test/RulesEngine.UnitTest/Issue749Test.cs new file mode 100644 index 00000000..b8a2ee51 --- /dev/null +++ b/test/RulesEngine.UnitTest/Issue749Test.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using RulesEngine.Models; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Dynamic.Core.CustomTypeProviders; +using System.Threading.Tasks; +using Xunit; + +namespace RulesEngine.UnitTest +{ + [DynamicLinqType] + [ExcludeFromCodeCoverage] + public static class Issue749ScannedType + { + public static bool IsSpecial(int value) => value == 42; + } + + [Trait("Category", "Unit")] + [ExcludeFromCodeCoverage] + public class Issue749Test + { + [Fact] + public void CustomTypeProvider_ScanningEnabled_IncludesDynamicLinqTypeMarkedType() + { + var provider = new CustomTypeProvider(Array.Empty(), enableAssemblyScanning: true); + + var allTypes = provider.GetCustomTypes(); + + Assert.Contains(typeof(Issue749ScannedType), allTypes); + } + + [Fact] + public void CustomTypeProvider_ScanningDisabled_ExcludesDynamicLinqTypeMarkedType() + { + var provider = new CustomTypeProvider(Array.Empty(), enableAssemblyScanning: false); + + var allTypes = provider.GetCustomTypes(); + + Assert.DoesNotContain(typeof(Issue749ScannedType), allTypes); + } + + [Fact] + public void CustomTypeProvider_ScanningDisabled_StillIncludesExplicitAndInputTypes() + { + var provider = new CustomTypeProvider(new[] { typeof(List) }, enableAssemblyScanning: false); + + var allTypes = provider.GetCustomTypes(); + + Assert.Contains(typeof(List), allTypes); + Assert.Contains(typeof(System.Linq.Enumerable), allTypes); + Assert.Contains(typeof(System.Collections.IEnumerable), allTypes); + } + + [Fact] + public async Task ScanningDisabled_BasicRuleAndInputMethods_StillWork() + { + var workflow = new Workflow + { + WorkflowName = "wf", + Rules = new[] { + new Rule { + RuleName = "R", + Expression = "input1.Value.ToString() == \"42\"" + } + } + }; + + var reSettings = new ReSettings { EnableAssemblyScanning = false }; + var engine = new RulesEngine(new[] { workflow }, reSettings); + + var result = await engine.ExecuteAllRulesAsync("wf", + new RuleParameter("input1", new { Value = 42 })); + + Assert.True(result[0].IsSuccess); + } + + [Fact] + public async Task ScanningEnabled_DynamicLinqTypeStaticMethod_Resolves() + { + var workflow = new Workflow + { + WorkflowName = "wf", + Rules = new[] { + new Rule { + RuleName = "R", + Expression = "Issue749ScannedType.IsSpecial(input1.Value)" + } + } + }; + + var reSettings = new ReSettings { EnableAssemblyScanning = true }; + var engine = new RulesEngine(new[] { workflow }, reSettings); + + var result = await engine.ExecuteAllRulesAsync("wf", + new RuleParameter("input1", new { Value = 42 })); + + Assert.True(result[0].IsSuccess); + } + + [Fact] + public async Task ScanningDisabled_TypeRegisteredExplicitly_StaticMethodResolves() + { + var workflow = new Workflow + { + WorkflowName = "wf", + Rules = new[] { + new Rule { + RuleName = "R", + Expression = "Issue749ScannedType.IsSpecial(input1.Value)" + } + } + }; + + var reSettings = new ReSettings { + EnableAssemblyScanning = false, + CustomTypes = new[] { typeof(Issue749ScannedType) } + }; + var engine = new RulesEngine(new[] { workflow }, reSettings); + + var result = await engine.ExecuteAllRulesAsync("wf", + new RuleParameter("input1", new { Value = 42 })); + + Assert.True(result[0].IsSuccess); + } + } +}