diff --git a/README.md b/README.md index 93a38bd..4d1c015 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,8 @@ var workflow = new Workflow } }; -workflow.Compile(compiler, new[] { compileParam }); +// Workflow owns its own compiler — pass parameters only (no compiler argument) +workflow.Compile(new[] { compileParam }); // Execute with real data var results = workflow.Execute(new[] { param }); @@ -132,9 +133,9 @@ using RoslynRules.Json; var workflow = JsonRuleLoader.LoadWorkflowFromFile("rules.json"); -// Compile before executing +// Compile before executing (Workflow.Compile takes parameters only) var compileParam = new RuleParameter("customer", typeof(Customer)); -workflow.Compile(compiler, new[] { compileParam }); +workflow.Compile(new[] { compileParam }); // Execute var customer = new Customer { Name = "Alice", Age = 25 }; @@ -202,23 +203,26 @@ Most rules engines parse expressions into trees and walk them every execution. R The `ExpressionCompiler` caches delegates in a `ConcurrentDictionary`. Compile a rule, and you get back the same delegate on subsequent calls. No recompilation, no assembly bloat. **Rules can depend on each other.** -Use `DependsOnRuleId` to build pipelines where one rule reads another's output. Dependencies are validated at compile time (no missing references) and resolved with topological sorting. +Use `DependsOnRuleId` to build pipelines where a dependent rule runs after its dependency. The dependency's `Action` mutates the shared parameter, and the dependent rule reads that value. Dependencies are validated at compile time (no missing references) and resolved with topological sorting. ```csharp var validate = new Rule { Description = "Validate", - Expression = "customer.IsActive" + Expression = "customer.IsActive", + Action = "customer.Validated = true" // write to the shared parameter }; var process = new Rule { Description = "Process", - DependsOnRuleId = validate.Id, - Expression = "context.GetResult(validate.Id).Success" + DependsOnRuleId = validate.Id, // runs after 'validate' + Expression = "customer.Validated" // reads what 'validate' wrote }; ``` +> Expressions and actions can only reference the parameters you pass to `Compile`/`Execute`. The `RuleContext` (`context.GetResult(...)`) is available to your host code, not inside expression strings — chain data by mutating the shared parameter object. + **Async without ceremony.** Expressions with `await` are auto-detected and compiled to async delegates. No manual `Task` wrapping. diff --git a/docs/aot-compatibility.md b/docs/aot-compatibility.md index 13d137b..e16646b 100644 --- a/docs/aot-compatibility.md +++ b/docs/aot-compatibility.md @@ -17,7 +17,6 @@ RoslynRules supports AOT deployment via **pre-compiled snapshots**. Rules cannot |-----------|----------|----------| | Load rules from JSON/EF | ✅ Yes | ✅ Yes | | `Workflow.Validate()` | ✅ Yes | ✅ Yes | -| `Workflow.GetExecutionOrder()` | ✅ Yes | ✅ Yes | | `Rule.Compile()` (from string) | ✅ Yes | ❌ **Not supported** | | `Rule.Execute()` without snapshot | ✅ Yes | ❌ Throws error | | `Rule.Execute()` with snapshot | ✅ Yes | ✅ Yes | @@ -129,14 +128,20 @@ Your app can also detect mode: ```csharp bool isAot = !RuntimeFeature.IsDynamicCodeSupported; +var serializer = new JsonSnapshotSerializer(); + if (isAot) { - workflow.LoadSnapshots("snapshots/"); + // Load a pre-compiled snapshot and restore the workflow. + var snapshot = SnapshotManager.LoadSnapshot(serializer, "snapshots/workflow.snap.json"); + var workflow = SnapshotManager.RestoreWorkflow(snapshot); } else { - workflow.Compile(parameters); - workflow.SaveSnapshots("snapshots/"); + // Compile and save a snapshot for later AOT execution. + var compiled = CompiledWorkflow.Compile(workflow, parameters); + var snapshot = compiled.ToSnapshot(); + SnapshotManager.SaveSnapshot(snapshot, serializer, "snapshots/workflow.snap.json"); } ``` @@ -148,7 +153,6 @@ These work in AOT without snapshots: - `Workflow` / `Rule` model creation - `Workflow.Validate()` — syntax validation -- `Workflow.GetExecutionOrder()` — dependency resolution - `RuleResult` creation and inspection - `RuleContext` result storage - `GraphAlgorithms.TopologicalSort()` @@ -170,7 +174,7 @@ The `.github/workflows/aot.yml` workflow validates AOT-safe APIs compile without dotnet test RoslynRules.Tests --filter "FullyQualifiedName~AotCompatibilityTests" ``` -This runs 8 tests covering model creation, validation, `RuleResult`, `RuleContext`, and execution order — all without runtime compilation. +These tests cover model creation, validation, `RuleResult`, `RuleContext`, and execution order — all without runtime compilation. --- diff --git a/docs/api-reference.md b/docs/api-reference.md index 34388fc..0aa0a6f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -24,7 +24,6 @@ Complete reference for all RoslynRules public APIs, organized by component. RuleParameterParameter definition (name, type, value) RuleDiagnosticsDiagnostics, logging, and auditing model RuleLifecycleEventsOnRuleExecuting/OnRuleExecuted event args - CompiledDelegateFast invocation wrapper for compiled delegates diff --git a/docs/api/assemblyreferenceprovider.md b/docs/api/assemblyreferenceprovider.md index aba8909..fa29e8f 100644 --- a/docs/api/assemblyreferenceprovider.md +++ b/docs/api/assemblyreferenceprovider.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 17 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # AssemblyReferenceProvider @@ -19,23 +19,43 @@ public class AssemblyReferenceProvider ## Default Behavior -The default provider includes a safe whitelist of common assemblies and excludes dangerous ones. +The default provider includes a safe whitelist of common assemblies (`DefaultWhitelist`) and +excludes a set of known dangerous assemblies (`KnownDangerousAssemblies`). Matching is a +case-insensitive substring (`Contains`) match against each loaded assembly's name. -**Included by default:** -- `System` +**Included by default (`DefaultWhitelist`):** +- `System.Runtime` +- `System.Private.CoreLib` +- `mscorlib` +- `netstandard` - `System.Core` - `System.Linq` +- `System.Linq.Expressions` - `System.Collections` -- `System.Text` +- `System.Text.Json` - `System.Text.RegularExpressions` +- `System.ComponentModel.Annotations` +- `RoslynRules` +- `Microsoft.Extensions.Logging.Abstractions` +- `Microsoft.CodeAnalysis` +- `Microsoft.CodeAnalysis.CSharp` - `Microsoft.CSharp` +- `Microsoft.CodeAnalysis.CSharp.Scripting` -**Excluded (dangerous):** +**Excluded (`KnownDangerousAssemblies`, always blocked):** - `System.IO` — file system access -- `System.Net` — network access -- `System.Reflection` — reflection abuse +- `System.IO.FileSystem` — file system access - `System.Diagnostics.Process` — process execution -- `System.Security` — security manipulation +- `System.Net.Http` — network access +- `System.Net.Sockets` — network access +- `System.Net.Security` — network access +- `System.Security.Cryptography` — cryptography +- `System.Reflection.Emit` — runtime code generation +- `System.Runtime.Loader` — assembly loading +- `System.Data.SqlClient` — database access +- `System.Data.OleDb` — database access +- `System.Data.Odbc` — database access +- `Microsoft.Win32.Registry` — registry access --- @@ -43,7 +63,7 @@ The default provider includes a safe whitelist of common assemblies and excludes ```csharp var provider = new AssemblyReferenceProvider(); -provider.AddAssembly(typeof(MyCustomType).Assembly); +provider.AllowAssembly("MyCompany.Models"); var del = compiler.Compile>( "customer.Name.Length > 0", @@ -56,11 +76,11 @@ var del = compiler.Compile>( ## Security Note -Even with a whitelist, never compile expressions from untrusted sources without validation. See [SECURITY.md](../SECURITY.md) for full details. +Even with a whitelist, never compile expressions from untrusted sources without validation. See [Security](../security.md) for full details. --- ## Related - [ExpressionCompiler](expressioncompiler.md) — Uses provider -- [SECURITY.md](../SECURITY.md) — Security guide +- [Security](../security.md) — Security guide diff --git a/docs/api/compiled-delegate.md b/docs/api/compiled-delegate.md index 21629c2..1aa8c73 100644 --- a/docs/api/compiled-delegate.md +++ b/docs/api/compiled-delegate.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 9 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # CompiledDelegate @@ -207,23 +207,22 @@ var result = compiled.Invoke(new[] ### Integration with Rule execution +> **Note:** `CompiledDelegate`, its wrappers, and `CompiledDelegateFactory` are `internal`. +> They are not part of the public API; the snippet below is conceptual and illustrates how the +> engine wraps a delegate internally. Application code should call `Rule.Compile(...)` / +> `Rule.Execute(...)` instead of constructing these types directly. + +`ExpressionCompiler.Compile` is generic and takes the parameter **names** as a +`string[]` (not a `RuleParameter[]`). For a single-parameter `Customer` expression: + ```csharp -public class RuleExecutor -{ - private readonly CompiledDelegate _compiledExpression; - - public RuleExecutor(Rule rule, ExpressionCompiler compiler) - { - var rawDelegate = compiler.Compile(rule.Expression, rule.Parameters); - _compiledExpression = CompiledDelegateFactory.Wrap(rawDelegate); - } - - public bool Evaluate(object parameter) - { - var result = _compiledExpression.Invoke(parameter); - return (bool)result!; - } -} +// Conceptual — illustrates internal wrapping. +var rawDelegate = compiler.Compile>( + "customer.IsActive", + new[] { "customer" }); + +var compiled = CompiledDelegateFactory.Wrap(rawDelegate); // internal +var result = compiled.Invoke(customer); // true / false (as object?) ``` --- @@ -247,4 +246,4 @@ For best performance, prefer **single-parameter** expressions in rules. Multi-pa - [ExpressionCompiler](expressioncompiler.md) — Compiles C# expressions to delegates - [Delegate Types](delegate-types.md) — Supported expression signatures - [Rule](rule.md) — Uses `CompiledDelegate` internally for expression evaluation -- [AOT Compatibility](aot-compatibility.md) — Notes on trimming and AOT limitations +- [AOT Compatibility](../aot-compatibility.md) — Notes on trimming and AOT limitations diff --git a/docs/api/delegate-types.md b/docs/api/delegate-types.md index d2d843a..592ba61 100644 --- a/docs/api/delegate-types.md +++ b/docs/api/delegate-types.md @@ -5,16 +5,20 @@ parent: API Reference nav_order: 7 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Delegate Types -RoslynRules supports exactly **one input parameter**. Return multiple values by wrapping them in a struct or class. +RoslynRules supports **up to 16 input parameters** per expression or action. Single-parameter +signatures take a fast, strongly-typed path; multi-parameter signatures (2–16 parameters) are +supported as well. To return multiple values from a single rule, wrap them in a struct or class. --- ## Supported Signatures +Single-parameter signatures (fastest path): + | Type | Delegate | Example | |------|----------|---------| | **Expression** | `Func` | `Func` | @@ -23,6 +27,15 @@ RoslynRules supports exactly **one input parameter**. Return multiple values by | **Async Expression** | `Func>` | `Func>` | | **Async Action** | `Func` | `Func` | +Multi-parameter signatures (up to 16 parameters) follow the same shapes: + +| Type | Delegate | Example | +|------|----------|---------| +| **Expression** | `Func` | `Func` | +| **Action** | `Action` | `Action` | +| **Async Expression** | `Func>` | `Func>` | +| **Async Action** | `Func` | `Func` | + --- ## Auto-Detection diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md index f372a15..6770507 100644 --- a/docs/api/exceptions.md +++ b/docs/api/exceptions.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 5 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Exceptions @@ -22,14 +22,14 @@ public abstract class RulesException : Exception | Exception | When Thrown | |-----------|-------------| | `RuleValidationException` | Rule has no Expression, Action, or active children | -| `CircularReferenceException` | Circular reference in child rules or `DependsOnRuleId` chain | -| `SyntaxErrorException` | Invalid C# syntax in expression or action | +| `CircularReferenceException` | Circular reference in the child rule tree (extends `RuleValidationException`) | +| `SyntaxErrorException` | Invalid C# syntax in expression or action (extends `RuleValidationException`) | | `RuleCompilationException` | Roslyn compilation failure | -| `NotCompiledException` | `Execute` called before `Compile` | -| `RuleExecutionException` | Runtime error in compiled code | +| `NotCompiledException` | `Execute` called before `Compile` (extends `RuleCompilationException`) | | `RuleTimeoutException` | Rule exceeded configured `Timeout` | | `WorkflowException` | Workflow has no active rules | -| `DuplicateRuleIdException` | Duplicate rule IDs in same workflow | +| `DuplicateRuleIdException` | Duplicate rule IDs in same workflow (extends `WorkflowException`) | +| `AotCompatibilityException` | A JIT-only API was called in an AOT/trimming environment | --- diff --git a/docs/api/expressioncompiler.md b/docs/api/expressioncompiler.md index 5283c12..4cf21ca 100644 --- a/docs/api/expressioncompiler.md +++ b/docs/api/expressioncompiler.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 6 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # ExpressionCompiler diff --git a/docs/api/graph-algorithms.md b/docs/api/graph-algorithms.md index 910b112..276e338 100644 --- a/docs/api/graph-algorithms.md +++ b/docs/api/graph-algorithms.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 7 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # GraphAlgorithms diff --git a/docs/api/iruleengine.md b/docs/api/iruleengine.md index e03e5dd..7793912 100644 --- a/docs/api/iruleengine.md +++ b/docs/api/iruleengine.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 12 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # IRuleEngine @@ -46,7 +46,7 @@ services.AddSingleton(); ```csharp var mock = new Mock(); mock.Setup(x => x.Execute(It.IsAny())) - .Returns(new[] { new RuleResult { Success = true } }); + .Returns(new[] { new RuleResult(true) }); var service = new MyService(mock.Object); ``` diff --git a/docs/api/json-serialization.md b/docs/api/json-serialization.md index 3695238..6030d52 100644 --- a/docs/api/json-serialization.md +++ b/docs/api/json-serialization.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 8 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # JSON Serialization @@ -67,15 +67,6 @@ JsonRuleLoader.SaveWorkflowToFile(workflow, "rules.json"); Custom `JsonSerializerOptions` with camelCase naming and indented output. ```csharp - ---- - -## Related - -- [Rule](rule.md) — Serialized model -- [Workflow](workflow.md) — Serialized container -- [Rule Templates](rule-templates.md) — Serialize templates before instantiation - var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -89,9 +80,10 @@ var json = JsonRuleLoader.Serialize(workflow, options); --- -## See Also +## Related -- [Getting Started](getting-started.md) -- [API Reference: Workflow](workflow.md) -- [API Reference: Rule](rule.md) +- [Rule](rule.md) — Serialized model +- [Workflow](workflow.md) — Serialized container +- [Rule Templates](rule-templates.md) — Serialize templates before instantiation +- [Getting Started](../getting-started.md) - [NuGet Package](https://www.nuget.org/packages/RoslynRules.Json) diff --git a/docs/api/lifecycle-events.md b/docs/api/lifecycle-events.md index ce1696b..993b40e 100644 --- a/docs/api/lifecycle-events.md +++ b/docs/api/lifecycle-events.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 15 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Lifecycle Events diff --git a/docs/api/result-caching.md b/docs/api/result-caching.md index a17cd2a..5a99b2c 100644 --- a/docs/api/result-caching.md +++ b/docs/api/result-caching.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 16 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Result Caching (Memoization) diff --git a/docs/api/rule-diagnostics.md b/docs/api/rule-diagnostics.md index ea8859b..3c87469 100644 --- a/docs/api/rule-diagnostics.md +++ b/docs/api/rule-diagnostics.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 6 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # RuleDiagnostics diff --git a/docs/api/rule-lifecycle-events.md b/docs/api/rule-lifecycle-events.md index cabf48b..34f1893 100644 --- a/docs/api/rule-lifecycle-events.md +++ b/docs/api/rule-lifecycle-events.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 8 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # RuleLifecycleEvents diff --git a/docs/api/rule-metrics.md b/docs/api/rule-metrics.md index 8d3821d..d49c7e4 100644 --- a/docs/api/rule-metrics.md +++ b/docs/api/rule-metrics.md @@ -6,7 +6,7 @@ parent: API Reference nav_order: 17 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Rule Metrics diff --git a/docs/api/rule-predicates.md b/docs/api/rule-predicates.md index 657e4d6..30ebdf3 100644 --- a/docs/api/rule-predicates.md +++ b/docs/api/rule-predicates.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 10 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Rule Predicates @@ -19,34 +19,61 @@ using RoslynRules.Predicates; ## Available Predicates +All methods are `static` and return a `Rule`. Every method accepts an optional trailing +`string? description = null` argument (omitted below for brevity). The `path` argument is a +parameter name and may be a dotted member-access path (e.g. `"order.CustomerId"`). + +### Null / Empty Checks + | Predicate | Description | |-----------|-------------| | `IsNotNull(path)` | Value is not null | -| `IsNull(path)` | Value is null | -| `Equals(path, value)` | Equality comparison | -| `NotEquals(path, value)` | Inequality comparison | -| `GreaterThan(path, value)` | `>` comparison | -| `GreaterThanOrEqual(path, value)` | `>=` comparison | -| `LessThan(path, value)` | `<` comparison | -| `LessThanOrEqual(path, value)` | `<=` comparison | -| `Between(path, min, max)` | Inclusive range check | +| `IsNotNullOrEmpty(path)` | String is not null or empty | +| `IsNotNullOrWhiteSpace(path)` | String is not null or whitespace | +| `IsNotEmpty(path)` | Collection has any elements (`.Any()`) | +| `IsEmpty(path)` | Collection has no elements (`!.Any()`) | + +### Comparison + +| Predicate | Description | +|-----------|-------------| +| `GreaterThan(path, value)` | `>` comparison (`T : struct`) | +| `GreaterThanOrEqual(path, value)` | `>=` comparison (`T : struct`) | +| `LessThan(path, value)` | `<` comparison (`T : struct`) | +| `LessThanOrEqual(path, value)` | `<=` comparison (`T : struct`) | +| `Equals(path, value)` | `==` comparison | +| `NotEquals(path, value)` | `!=` comparison | +| `InRange(path, min, max)` | Inclusive range check (`T : struct`) | +| `NotInRange(path, min, max)` | Outside exclusive range (`T : struct`) | + +### String + +| Predicate | Description | +|-----------|-------------| | `MatchesRegex(path, pattern)` | Regex match | -| `Contains(path, value)` | String/collection contains | +| `Contains(path, value)` | String contains substring | | `StartsWith(path, value)` | String prefix | | `EndsWith(path, value)` | String suffix | -| `IsEmpty(path)` | String/collection empty | -| `IsNotEmpty(path)` | String/collection not empty | -| `HasLength(path, min, max)` | Length in range | -| `IsIn(path, values)` | Value in set | -| `IsNotIn(path, values)` | Value not in set | -| `IsTrue(path)` | Boolean true | -| `IsFalse(path)` | Boolean false | -| `IsDateBefore(path, date)` | Date comparison | -| `IsDateAfter(path, date)` | Date comparison | -| `IsDateBetween(path, start, end)` | Date range | -| `IsGuid(path)` | Valid GUID format | -| `IsEmail(path)` | Valid email format | -| `IsUrl(path)` | Valid URL format | +| `HasLength(path, length)` | String has exact length | +| `HasMinLength(path, minLength)` | String length `>=` minLength | +| `HasMaxLength(path, maxLength)` | String length `<=` maxLength | + +### Collection + +| Predicate | Description | +|-----------|-------------| +| `CountEquals(path, count)` | Collection count `==` count | +| `CountGreaterThan(path, count)` | Collection count `>` count | +| `CountLessThan(path, count)` | Collection count `<` count | +| `Contains(path, value)` | Collection contains element | + +### Boolean / Type + +| Predicate | Description | +|-----------|-------------| +| `IsTrue(path)` | Boolean is true | +| `IsFalse(path)` | Boolean is false | +| `IsOfType(path)` | Value is of type `T` | --- @@ -60,7 +87,8 @@ var workflow = new Workflow RulePredicates.IsNotNull("customer"), RulePredicates.GreaterThan("customer.Age", 18), RulePredicates.MatchesRegex("customer.Email", @"^[^@]+@[^@]+\.[^@]+$"), - RulePredicates.IsIn("customer.Status", new[] { "Active", "Pending" }) + RulePredicates.InRange("customer.Score", 0, 100), + RulePredicates.HasMinLength("customer.Name", 2) } }; ``` diff --git a/docs/api/rule-priority.md b/docs/api/rule-priority.md index 673cac6..90226b4 100644 --- a/docs/api/rule-priority.md +++ b/docs/api/rule-priority.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 14 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Rule Priority diff --git a/docs/api/rule-templates.md b/docs/api/rule-templates.md index d3fbe74..02038a3 100644 --- a/docs/api/rule-templates.md +++ b/docs/api/rule-templates.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 9 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Rule Templates @@ -34,9 +34,11 @@ public class RuleTemplate ### Methods -#### `Instantiate(Dictionary, ExpressionCompiler, RuleParameter[], string[]?)` +#### `Instantiate(Dictionary, ExpressionCompiler, RuleParameter[], string[], AssemblyReferenceProvider?)` -Creates a compiled `Rule` from the template with placeholder values substituted. +Creates a compiled `Rule` from the template with placeholder values substituted. The trailing +`AssemblyReferenceProvider? referenceProvider = null` argument is optional and controls +compilation sandboxing. ```csharp var template = new RuleTemplate @@ -50,12 +52,18 @@ var values = new Dictionary { ["minAge"] = 18 }; var rule = template.Instantiate(values, compiler, parameters, Array.Empty()); ``` -#### `ExtractPlaceholders(string)` +#### `ExtractPlaceholders()` -Extracts placeholder names from an expression string. +Instance method that extracts placeholder names from this template's `Expression` (no +parameters). Returns `IReadOnlyList`. ```csharp -var names = RuleTemplate.ExtractPlaceholders("customer.Age >= {minAge} && customer.Score >= {minScore}"); +var template = new RuleTemplate +{ + Expression = "customer.Age >= {minAge} && customer.Score >= {minScore}" +}; + +IReadOnlyList names = template.ExtractPlaceholders(); // ["minAge", "minScore"] ``` diff --git a/docs/api/rule-visualization.md b/docs/api/rule-visualization.md index cf5a8bb..ff53273 100644 --- a/docs/api/rule-visualization.md +++ b/docs/api/rule-visualization.md @@ -47,12 +47,14 @@ var workflow = new Workflow { Description = "Check inventory", Expression = "inventory.HasStock", + Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), DependsOnRuleId = Guid.Parse("11111111-1111-1111-1111-111111111111") }, new Rule { Description = "Apply discount", Expression = "order.Total > 100", + Id = Guid.Parse("33333333-3333-3333-3333-333333333333"), DependsOnRuleId = Guid.Parse("22222222-2222-2222-2222-222222222222") } } @@ -61,7 +63,8 @@ var workflow = new Workflow Console.WriteLine(RuleGraphVisualizer.ToMermaid(workflow)); ``` -**Output:** +**Output (illustrative — node ids correspond to the rule ids above; the `:::inactive` +styling is shown only to demonstrate how inactive rules render):** ```mermaid graph TD R11111111111111111111111111111111[Validate customer] diff --git a/docs/api/rule.md b/docs/api/rule.md index 8cdde49..a70e37f 100644 --- a/docs/api/rule.md +++ b/docs/api/rule.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 1 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Rule diff --git a/docs/api/rulebatch.md b/docs/api/rulebatch.md index ac1f33c..ecced4c 100644 --- a/docs/api/rulebatch.md +++ b/docs/api/rulebatch.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 13 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # RuleBatch diff --git a/docs/api/rulecontext.md b/docs/api/rulecontext.md index 865a382..d3b09b5 100644 --- a/docs/api/rulecontext.md +++ b/docs/api/rulecontext.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 11 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # RuleContext @@ -21,13 +21,14 @@ public class RuleContext ### `GetResult(Guid)` -Gets the `RuleResult` for a specific rule ID. +Gets the `RuleResult` for a specific rule ID. Returns `RuleResult?` (`null` if the rule has +not executed yet). ```csharp -var dependencyResult = context.GetResult(validateCustomer.Id); -if (dependencyResult.Success) +RuleResult? dependencyResult = context.GetResult(validateCustomer.Id); +if (dependencyResult is { Success: true }) { - // dependency passed + // dependency executed and passed } ``` @@ -68,20 +69,39 @@ context.StoreResult(rule.Id, result); ## Usage with DependsOnRuleId +`RuleContext` is a **host-side** API. There is no `context` variable in scope inside an +`Expression` or `Action` string — those strings may only reference declared `RuleParameter`s. +The host code reads dependency results from the context after (or between) rule execution. + ```csharp var taxRule = new Rule { Description = "Calculate tax", - Action = "customer.TaxAmount = customer.Amount * 0.08" + // Expressions/actions reference declared parameters only — never "context". + Action = "customer.TaxAmount = customer.Amount * 0.08m" }; var totalRule = new Rule { Description = "Calculate total", DependsOnRuleId = taxRule.Id, - Expression = "context.TryGetValue(taxRule.Id, out var tax)", - Action = "customer.Total = customer.Amount + tax" + Action = "customer.Total = customer.Amount + customer.TaxAmount" }; + +// Host code inspects dependency results via RuleContext. +var context = new RuleContext(); +// ... engine stores each rule's result into the context as it runs ... + +if (context.TryGetValue(taxRule.Id, out var tax)) +{ + Console.WriteLine($"Tax computed by dependency: {tax}"); +} + +RuleResult? taxResult = context.GetResult(taxRule.Id); +if (taxResult is { Success: true }) +{ + // proceed knowing the tax rule succeeded +} ``` --- diff --git a/docs/api/ruleparameter.md b/docs/api/ruleparameter.md index 1b592ef..d40d94c 100644 --- a/docs/api/ruleparameter.md +++ b/docs/api/ruleparameter.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 4 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # RuleParameter diff --git a/docs/api/ruleresult.md b/docs/api/ruleresult.md index 5c0ecaf..bb03a3b 100644 --- a/docs/api/ruleresult.md +++ b/docs/api/ruleresult.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 3 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # RuleResult @@ -34,7 +34,7 @@ public readonly record struct RuleResult | `Exception` | `Exception?` | Exception if execution failed | | `ChildResults` | `IReadOnlyList` | Nested results from child rules | | `FirstFailure` | `RuleResult?` | First failing child (or `null` if all passed) | -| `AllFailures` | `IEnumerable` | All failing children (recursive) | +| `AllFailures` | `IEnumerable` | Direct failing children (not recursive) | --- @@ -78,4 +78,4 @@ foreach (var child in result.ChildResults) ## Related - [Rule](rule.md) — Produces RuleResult -- [RuleTest](rule-test.md) — Testing framework assertions +- [Testing Framework](../examples/testing-framework.md) — Testing framework assertions diff --git a/docs/api/workflow.md b/docs/api/workflow.md index 04b7ffd..93fdfe2 100644 --- a/docs/api/workflow.md +++ b/docs/api/workflow.md @@ -5,7 +5,7 @@ parent: API Reference nav_order: 2 --- -[← Back to API Reference](api-reference.md) +[← Back to API Reference](../api-reference.md) # Workflow diff --git a/docs/architecture.md b/docs/architecture.md index 6323367..9428002 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,30 +102,33 @@ Subsequent calls with the same signature return the cached delegate instantly. ## Execution Model +The compiled delegate held by each rule is an internal implementation detail (the +`CompiledDelegate` hierarchy is `internal`). Single-parameter rules use a direct typed +invoke; multi-parameter rules dispatch via `DynamicInvoke`. The snippets below show the +**public** execution surface that drives that machinery. + ### Sequential ```csharp -foreach (var rule in workflow.Rules) +foreach (var result in workflow.Execute(parameters)) { - var result = rule.CompiledDelegate.Invoke(parameters); - context.StoreResult(rule.Id, result); + // Results are yielded in dependency order. } ``` ### Parallel ```csharp -Parallel.ForEach(rules, rule => { - var result = rule.CompiledDelegate.Invoke(parameters); - // Thread-safe result storage -}); +RuleResult[] results = workflow.ExecuteParallel(parameters); ``` ### Async ```csharp -var asyncDel = (Func>)rule.CompiledDelegate; -var result = await asyncDel(customer); +await foreach (var result in workflow.ExecuteAsync(parameters)) +{ + // Awaitable rules run asynchronously. +} ``` --- diff --git a/docs/examples/index.md b/docs/examples/index.md index 1d97fc0..f590a31 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -174,7 +174,7 @@ var rule = new Rule }" }; -// Access result.Data as ValidationResult +// Access result.Value as ValidationResult ``` ## Workflow with Multiple Rules @@ -204,7 +204,7 @@ var results = workflow.ExecuteParallel(parameters); ## Dependency Chaining -Chain rules with `DependsOnRuleId` so downstream rules access upstream results via `RuleContext`. +Chain rules with `DependsOnRuleId` so each upstream rule's `Action` mutates the shared parameter object and downstream rules read those mutated properties. Compiled expressions can only reference the declared parameters — there is no `context` variable inside expression strings. ```csharp var authId = Guid.NewGuid(); @@ -212,6 +212,7 @@ var authRule = new Rule(authId) { Description = "Authenticated?", Expression = "user.IsAuthenticated", + Action = "user.IsAuthenticated = true", IsActive = true }; @@ -219,7 +220,8 @@ var adminId = Guid.NewGuid(); var adminRule = new Rule(adminId) { Description = "Admin?", - Expression = "context.GetValue(authId) && user.Role == \"Admin\"", + Expression = "user.IsAuthenticated && user.Role == \"Admin\"", + Action = "user.IsAdmin = user.IsAuthenticated && user.Role == \"Admin\"", DependsOnRuleId = authId, IsActive = true }; @@ -228,7 +230,7 @@ var discountId = Guid.NewGuid(); var discountRule = new Rule(discountId) { Description = "VIP discount", - Expression = "context.GetValue(adminId) && user.IsVip", + Expression = "user.IsAdmin && user.IsVip", DependsOnRuleId = adminId, IsActive = true }; @@ -249,7 +251,8 @@ var results = workflow.Execute(new[] **Key points:** - `DependsOnRuleId` ensures execution order -- `RuleContext.GetValue(ruleId)` retrieves upstream results +- Upstream rules pass data downstream by mutating the shared parameter object in their `Action`; downstream rules read the mutated properties +- The host-side `RuleContext.GetValue(ruleId)` API can inspect results from C# code, but is not available inside expression strings - Cyclic dependencies throw `CircularReferenceException` on `Validate()` ## Logging with Serilog @@ -317,7 +320,7 @@ Expression = "((dynamic)customer).Age != null && ((dynamic)customer).Age >= 18" Store rules in JSON files for configuration-driven setups: ```csharp -using RoslynRules.Extensions; +using RoslynRules.Json; // Load from JSON var workflow = JsonRuleLoader.LoadWorkflowFromFile("customer-rules.json"); @@ -353,7 +356,7 @@ Combine rules from multiple sources into a single batch: ```csharp using RoslynRules.Batch; -using RoslynRules.Extensions; +using RoslynRules.Json; var batch = new RuleBatch(); @@ -365,7 +368,7 @@ batch.AddRule(new Rule }); // From JSON file -var jsonWorkflow = JsonRuleLoader.LoadFromFile("compliance-rules.json"); +var jsonWorkflow = JsonRuleLoader.LoadWorkflowFromFile("compliance-rules.json"); batch.AddRules(jsonWorkflow.Rules); // From database (EF Core) @@ -533,5 +536,5 @@ foreach (var error in errors) { Console.WriteLine($"[{error.ErrorType}] {error.Message}"); } -// ErrorType values: NoActiveRules, EmptyRule, SyntaxError, CircularReference, DuplicateRuleId, MissingDependency +// ErrorType values: NoActiveRules, EmptyRule, CircularReference, SyntaxError, DuplicateRuleId, MissingDependency, General ``` diff --git a/docs/examples/rule-action-chaining.md b/docs/examples/rule-action-chaining.md index 1bbc72b..30d5d3f 100644 --- a/docs/examples/rule-action-chaining.md +++ b/docs/examples/rule-action-chaining.md @@ -85,7 +85,7 @@ var checkCredit = new Rule 1. Validate customer 2. Check credit (only after validate customer completes) -**Failure behavior:** If validate customer fails, check credit still runs. Check credit can use `RuleContext` to see the dependency's result and decide what to do. +**Failure behavior:** If validate customer fails, check credit still runs. The upstream rule's `Action` can mutate the shared parameter object, and check credit reads those mutated properties in its `Expression`/`Action`. ### Comparison Table @@ -172,8 +172,8 @@ var finalDecision = new Rule { Description = "Final decision", DependsOnRuleId = evaluateRisk.Id, - Expression = "true", - Action = "customer.Approved = context.GetResult(evaluateRisk.Id).Success", + Expression = "customer.CreditScore >= 600", + Action = "customer.Approved = true", IsActive = true }; @@ -192,9 +192,11 @@ var results = workflow.Execute(parameters).ToList(); ## Accessing Dependency Results -Use `RuleContext` to read the results of previously executed rules. +To pass data between dependent rules, have the upstream rule's `Action` mutate the shared parameter object, and have the downstream rule read those mutated properties in its `Expression`/`Action`. Compiled expressions and actions can only reference the declared `RuleParameter` objects — there is no `context` variable in scope inside an expression string. + +`RuleContext` is a host-side (C#) API for inspecting results after execution. Its `GetResult`/`GetValue`/`TryGetValue` methods can only be called from your own C# code, never from inside an expression or action string. -### RuleContext API +### RuleContext API (host-side only) ```csharp public class RuleContext @@ -219,14 +221,16 @@ public class RuleContext } ``` -### Reading Values in Expressions +### Passing Values Between Rules + +The upstream rule mutates a property on the shared parameter object; the downstream rule reads it. ```csharp var calculateTax = new Rule { Description = "Calculate tax", - Expression = "true", - Action = "customer.TaxAmount = customer.Amount * 0.08", + Expression = "customer.Amount > 0", + Action = "customer.TaxAmount = customer.Amount * 0.08m", IsActive = true }; @@ -234,13 +238,15 @@ var calculateTotal = new Rule { Description = "Calculate total", DependsOnRuleId = calculateTax.Id, - Expression = "true", - Action = @"customer.Total = customer.Amount + context.GetValue(calculateTax.Id)", + Expression = "customer.TaxAmount > 0", + Action = "customer.Total = customer.Amount + customer.TaxAmount", IsActive = true }; ``` -### Using TryGetValue for Safe Access +### Guarding Against Unset Values + +If the upstream rule may not have set a value, guard the downstream read against the property's default. ```csharp var calculateTotal = new Rule @@ -248,26 +254,23 @@ var calculateTotal = new Rule Description = "Calculate total", DependsOnRuleId = calculateTax.Id, Expression = "true", - Action = @" - if (context.TryGetValue(calculateTax.Id, out var taxAmount)) - { - customer.Total = customer.Amount + taxAmount; - } - else - { - customer.Total = customer.Amount; // No tax applied - }", + Action = @"customer.Total = customer.TaxAmount > 0 + ? customer.Amount + customer.TaxAmount + : customer.Amount;", IsActive = true }; ``` ### Conditional Logic Based on Dependencies +The upstream rule records its outcome on the shared object; the downstream rule reads it. + ```csharp var checkAuthentication = new Rule { Description = "Check authentication", Expression = "customer.IsAuthenticated", + Action = "customer.IsAuthenticated = true", IsActive = true }; @@ -275,7 +278,7 @@ var checkAuthorization = new Rule { Description = "Check authorization", DependsOnRuleId = checkAuthentication.Id, - Expression = @"context.GetResult(checkAuth.Id).Success && customer.HasAdminRole", + Expression = @"customer.IsAuthenticated && customer.HasAdminRole", IsActive = true }; ``` @@ -355,7 +358,8 @@ Use a dependency as a gatekeeper. The dependent rule always runs but checks the var gatekeeper = new Rule { Description = "Feature enabled", - Expression = "FeatureFlags.IsEnabled("PremiumFeature")", + Expression = "FeatureFlags.IsEnabled(\"PremiumFeature\")", + Action = "customer.FeatureEnabled = FeatureFlags.IsEnabled(\"PremiumFeature\")", IsActive = true }; @@ -363,7 +367,7 @@ var premiumCheck = new Rule { Description = "Premium validation", DependsOnRuleId = gatekeeper.Id, - Expression = "context.GetResult(gatekeeper.Id).Success && customer.IsPremium", + Expression = "customer.FeatureEnabled && customer.IsPremium", IsActive = true }; ``` @@ -408,6 +412,7 @@ var eligibilityCheck = new Rule { Description = "Eligibility", Expression = "customer.Age >= 18 && customer.Income > 30000", + Action = "customer.IsEligible = customer.Age >= 18 && customer.Income > 30000", IsActive = true }; @@ -415,7 +420,7 @@ var standardOffer = new Rule { Description = "Standard offer", DependsOnRuleId = eligibilityCheck.Id, - Expression = "context.GetResult(eligibility.Id).Success && customer.Income < 100000", + Expression = "customer.IsEligible && customer.Income < 100000", IsActive = true }; @@ -423,7 +428,7 @@ var premiumOffer = new Rule { Description = "Premium offer", DependsOnRuleId = eligibilityCheck.Id, - Expression = "context.GetResult(eligibility.Id).Success && customer.Income >= 100000", + Expression = "customer.IsEligible && customer.Income >= 100000", IsActive = true }; ``` @@ -445,7 +450,8 @@ var validateAge = new Rule { Description = "Validate age", DependsOnRuleId = parseInput.Id, - Expression = @"context.GetResult(parse.Id).Success && customer.ParsedAge >= 18", + Expression = @"customer.ParsedAge >= 18", + Action = "customer.IsAdult = customer.ParsedAge >= 18", IsActive = true }; @@ -453,8 +459,8 @@ var processAdult = new Rule { Description = "Process adult", DependsOnRuleId = validateAge.Id, - Expression = @"context.GetResult(validateAge.Id).Success", - Action = "customer.IsAdult = true", + Expression = @"customer.IsAdult", + Action = "customer.Processed = true", IsActive = true }; ``` @@ -473,11 +479,11 @@ var processAdult = new Rule **Fix:** Redesign rules to break the cycle. Use parent-child relationships for tight coupling instead. -### "Expected dependency result but context was empty" +### Downstream rule reads a stale or default value -**Cause:** Rule is trying to access `context.GetResult()` but the dependency hasn't executed yet. +**Cause:** The downstream rule ran before the upstream rule mutated the shared parameter object, or the upstream `Action` never set the property. -**Fix:** Ensure `DependsOnRuleId` is set correctly. The workflow engine executes dependencies first automatically. +**Fix:** Ensure `DependsOnRuleId` is set correctly so the dependency runs first, and confirm the upstream rule's `Action` assigns the property the downstream rule reads. Remember: compiled expressions/actions can only reference the declared parameters — there is no `context` available inside expression strings. Use the host-side `RuleContext` only from C# code after execution. ### Priority not respected diff --git a/docs/examples/testing-framework.md b/docs/examples/testing-framework.md index 738ec43..ea6675e 100644 --- a/docs/examples/testing-framework.md +++ b/docs/examples/testing-framework.md @@ -32,9 +32,11 @@ var test = RuleTest.For(adultRule) .ExpectAllChildrenPass() .ExpectValue(true); +// Run() throws RuleAssertionException if any expectation fails, +// otherwise returns the RuleResult. var result = test.Run(); -// result.Passed = true -// result.ErrorMessage = null +// result.Success = true +// result.Exception = null ``` ### Testing Failure Cases @@ -42,12 +44,11 @@ var result = test.Run(); ```csharp var test = RuleTest.For(adultRule) .WithInput("customer", new Customer { Age = 16, Name = "Bob" }) - .ExpectFailure() - .ExpectNoException(); + .ExpectFailure(); var result = test.Run(); -// result.Passed = true -// result.RuleResult.Success = false +// result.Success = false +// result.Exception = null ``` ### Testing with Multiple Inputs @@ -107,6 +108,8 @@ var parent = new Rule } }; +var compiler = new ExpressionCompiler(); +parent.Compile(compiler, parameters); var result = parent.Execute(parameters); // All children passed @@ -129,10 +132,11 @@ result.ShouldHaveChild("Child 2").ShouldFail(); var badRule = new Rule { Description = "Bad rule", - Expression = "throw new ArgumentException("test")" + Expression = "throw new ArgumentException(\"test\")" }; -badRule.Compile(parameters); +var compiler = new ExpressionCompiler(); +badRule.Compile(compiler, parameters); var result = badRule.Execute(parameters); // Assert exception type @@ -189,15 +193,15 @@ Console.WriteLine(result.ToString()); // Rule Test Suite: 3 passed, 0 failed (3 total) // ✅ PASS Adult check // ✅ PASS Name check -// ✅ FAIL Inactive rule (expected) +// ✅ PASS Inactive rule // Detailed results -foreach (var testResult in result.Results) +foreach (var testResult in result.Tests) { - Console.WriteLine($"{testResult.RuleDescription}: {(testResult.Passed ? "PASS" : "FAIL")}"); + Console.WriteLine($"{testResult.TestName}: {(testResult.Passed ? "PASS" : "FAIL")}"); if (!testResult.Passed) { - Console.WriteLine($" Error: {testResult.ErrorMessage}"); + Console.WriteLine($" Error: {testResult.Exception?.Message}"); } } ``` @@ -251,7 +255,7 @@ var test = RuleTest.For(rule) var test = RuleTest.For(rule) .WithInput("customer", customer) .ExpectSuccess() - .ExpectValueOfType() + .Assert(r => r.ShouldHaveValueOfType()) .Assert(r => { var result = (ValidationResult)r.Value; @@ -353,14 +357,15 @@ public void AdultRule_WithAdultCustomer_ShouldPass() new RuleParameter("customer", typeof(Customer), new Customer { Age = 25 }) }; - rule.Compile(parameters); + var compiler = new ExpressionCompiler(); + rule.Compile(compiler, parameters); var result = rule.Execute(parameters); // Framework assertions result.ShouldPass(); // FluentAssertions for complex checks - result.ElapsedMilliseconds.Should().BeLessThan(1); + result.Success.Should().BeTrue(); result.RuleDescription.Should().Be("Adult check"); result.ChildResults.Should().BeEmpty(); } @@ -406,7 +411,8 @@ public class AgeRuleTests IsActive = true }; _parameters = new[] { new RuleParameter("customer", typeof(Customer), default) }; - _rule.Compile(_parameters); + var compiler = new ExpressionCompiler(); + _rule.Compile(compiler, _parameters); } [Fact] @@ -452,8 +458,9 @@ public void AllValidationRules_ShouldPassWithValidCustomer() public void Rule_ShouldCompileSuccessfully() { var rule = new Rule { Expression = "customer.Age >= 18" }; - - var act = () => rule.Compile(parameters); + var compiler = new ExpressionCompiler(); + + var act = () => rule.Compile(compiler, parameters); act.Should().NotThrow(); } @@ -461,9 +468,10 @@ public void Rule_ShouldCompileSuccessfully() public void Rule_WithSyntaxError_ShouldThrow() { var rule = new Rule { Expression = "customer.Age >= " }; // Syntax error - - var act = () => rule.Compile(parameters); - act.Should().Throw(); + var compiler = new ExpressionCompiler(); + + var act = () => rule.Compile(compiler, parameters); + act.Should().Throw(); } ``` diff --git a/docs/examples/when-to-use-what.md b/docs/examples/when-to-use-what.md index eea6065..5927dbe 100644 --- a/docs/examples/when-to-use-what.md +++ b/docs/examples/when-to-use-what.md @@ -41,7 +41,8 @@ var ageCheck = new Rule IsActive = true }; -ageCheck.Compile(parameters); +var compiler = new ExpressionCompiler(); +ageCheck.Compile(compiler, parameters); var result = ageCheck.Execute(parameters); // result.Success tells you pass/fail @@ -67,7 +68,7 @@ var workflow = new Workflow Rules = new List { new Rule { Description = "Age", Expression = "customer.Age >= 18" }, - new Rule { Description = "Email", Expression = "customer.Email.Contains("@")" }, + new Rule { Description = "Email", Expression = "customer.Email.Contains(\"@\")" }, new Rule { Description = "Country", Expression = "customer.Country != null" } } }; diff --git a/docs/getting-started.md b/docs/getting-started.md index 2a55675..f052021 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -96,6 +96,7 @@ var parameters = new[] RuleParameter.ForCompile("quantity", typeof(int)) }; +var compiler = new ExpressionCompiler(); rule.Compile(compiler, parameters); ``` diff --git a/docs/index.md b/docs/index.md index 66ce736..eb0cc02 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ High-performance .NET rules engine with Roslyn compilation, typed delegates, and Roslyn CompilationExpressions compile to IL once, execute as native code - Typed DelegatesDirect Func<T,R> calls — no DynamicInvoke overhead + Typed DelegatesSingle-parameter rules invoke Func<T,R> directly — no DynamicInvoke overhead (multi-parameter rules use DynamicInvoke) Single ParameterOne input, one output — no array allocation per call Immutable RulesLock after compile — zero thread contention Parallel ExecutionParallel.For for independent rule evaluation diff --git a/docs/security.md b/docs/security.md index e1e5a1e..b4c19bc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -137,13 +137,21 @@ var rule = new Rule ## Dependency Patterns -Use `DependsOnRuleId` to chain rules securely — data flows through `RuleContext`, not shared state: +Use `DependsOnRuleId` to chain rules securely. Expressions and actions can only reference declared `RuleParameter`s — there is no `context` variable in scope. To pass data between rules, have the upstream rule's `Action` mutate a shared parameter object and have the downstream rule read the mutated property, ordering them with `DependsOnRuleId`: ```csharp -var auth = new Rule { Id = authId, Expression = "user.IsAuthenticated" }; +// Upstream rule writes the authorization result onto the shared parameter. +var auth = new Rule +{ + Id = authId, + Expression = "user.IsAuthenticated", + Action = "session.IsAuthorized = user.IsAuthenticated" +}; + +// Downstream rule reads the mutated property; ordering is enforced via DependsOnRuleId. var admin = new Rule { - Expression = "context.GetValue(authId)", + Expression = "session.IsAuthorized", DependsOnRuleId = authId }; ``` @@ -157,7 +165,7 @@ var admin = new Rule - [ ] Set `Timeout` on untrusted rules - [ ] Monitor `CompileCount` and ALC recycling - [ ] Never include `System.IO`, `System.Net`, `System.Diagnostics` -- [ ] Use `RuleContext` for dependency data, not shared mutable state +- [ ] Pass cross-rule data through declared `RuleParameter`s mutated by upstream actions, ordered via `DependsOnRuleId` - [ ] Log compilation failures for audit trail --- diff --git a/docs/snapshots.md b/docs/snapshots.md index b79c740..18c5252 100644 --- a/docs/snapshots.md +++ b/docs/snapshots.md @@ -71,16 +71,14 @@ var workflow = new Workflow } }; -// 2. Compile the workflow (JIT only) +// 2. Define compile-time parameters (JIT only) var parameters = new[] { new RuleParameter("order", typeof(Order), null), new RuleParameter("customer", typeof(Customer), null) }; -workflow.Compile(parameters); - -// 3. Create a snapshot +// 3. Create a snapshot (CompiledWorkflow.Compile compiles the workflow internally) var snapshot = SnapshotManager.CreateSnapshot( CompiledWorkflow.Compile(workflow, parameters)); @@ -158,7 +156,6 @@ public static void Main(string[] args) var workflow = JsonRuleLoader.LoadWorkflowFromFile(ruleFile); var parameters = GetParametersFor(ruleFile); - workflow.Compile(parameters); var snapshot = SnapshotManager.CreateSnapshot( CompiledWorkflow.Compile(workflow, parameters)); @@ -258,5 +255,3 @@ if (!snapshot.Version.IsCompatibleWith(expectedVersion)) - [AOT Compatibility](./aot-compatibility.md) — Detailed AOT detection and troubleshooting - [Performance Tuning](./performance-tuning.md) — Optimize rule execution -- [API Reference: SnapshotManager](../api/snapshotmanager.md) -- [API Reference: ISnapshotSerializer](../api/isnapshotserializer.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5734a29..5e8c408 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -66,6 +66,7 @@ Expression = "awaiting > 0" // This is sync **Fix:** Compile once before executing: ```csharp +var compiler = new ExpressionCompiler(); rule.Compile(compiler, parameters); var result = rule.Execute(parameters); // Now works ```