diff --git a/src/modules/sql/Elsa.Sql/Services/SqlEvaluator.cs b/src/modules/sql/Elsa.Sql/Services/SqlEvaluator.cs index ed1fc46a..645f4aa6 100644 --- a/src/modules/sql/Elsa.Sql/Services/SqlEvaluator.cs +++ b/src/modules/sql/Elsa.Sql/Services/SqlEvaluator.cs @@ -1,4 +1,7 @@ -using System.Text; +using System.Collections; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.Sql.Contracts; @@ -75,20 +78,165 @@ public async Task EvaluateAsync( private object? ResolveValue(string key) { - return key switch + switch (key) { - "Workflow.Definition.Id" => executionContext.Workflow.Identity.DefinitionId, - "Workflow.Definition.Version.Id" => executionContext.Workflow.Identity.Id, - "Workflow.Definition.Version" => executionContext.Workflow.Identity.Version, - "Workflow.Instance.Id" => activityContext.WorkflowExecutionContext.Id, - "Correlation.Id" => activityContext.WorkflowExecutionContext.CorrelationId, - "LastResult" => expressionContext.GetLastResult(), - var i when i.StartsWith("Input.") => executionContext.Input.TryGetValue(i.Substring(6), out var v) ? v : null, - var o when o.StartsWith("Output.") => executionContext.Output.TryGetValue(o.Substring(7), out var v) ? v : null, - var v when v.StartsWith("Variable.") => expressionContext.GetVariableInScope(v.Substring(9)) ?? null, - // OBSOLETE: This is deprecated and will be removed in a future version. Use 'Variable.' instead. - var v when v.StartsWith("Variables.") => expressionContext.GetVariableInScope(v.Substring(10)) ?? null, - _ => throw new NullReferenceException($"No matching property found for {{{{{key}}}}}.") - }; + case var k when k.StartsWith("Input."): + { + var (rootKey, nestedPath) = GetRootAndPath(k, "Input."); + executionContext.Input.TryGetValue(rootKey, out var root); + return ResolveNestedValue(root, nestedPath); + } + case var k when k.StartsWith("Output."): + { + var (rootKey, nestedPath) = GetRootAndPath(k, "Output."); + executionContext.Output.TryGetValue(rootKey, out var root); + return ResolveNestedValue(root, nestedPath); + } + case var k when k.StartsWith("Variable."): + { + var (rootKey, nestedPath) = GetRootAndPath(k, "Variable."); + var root = expressionContext.GetVariableInScope(rootKey); + return ResolveNestedValue(root, nestedPath); + } + case var k when k.StartsWith("Activity."): + { + var (rootKey, nestedPath) = GetRootAndPath(k, "Activity."); + var root = activityContext; + return ResolveNestedValue(root, nestedPath); + } + case var k when k.StartsWith("Execution."): + { + var (rootKey, nestedPath) = GetRootAndPath(k, "Execution."); + var root = executionContext; + return ResolveNestedValue(root, nestedPath); + } + case var k when k.StartsWith("Workflow."): + { + var (rootKey, nestedPath) = GetRootAndPath(k, "Workflow."); + var root = executionContext.Workflow; + return ResolveNestedValue(root, nestedPath); + } + case "LastResult": + return expressionContext.GetLastResult(); + default: + throw new NullReferenceException($"No matching property found for {{{{{key}}}}}."); + } + } + + /// + /// Extracts the root key and the nested path from the specified key, based on the given prefix. + /// + /// The full key from which the root key and nested path are derived. Must start with the specified . + /// The prefix to remove from the beginning of to determine the root key and nested path. + /// A tuple containing the root key and the nested path: rootKey: The + /// portion of the key before the first '.' or '[' after the prefix. + /// nestedPath: The remaining portion of the key after the root key. Returns an empty + /// string if no '.' or '[' is found. + private (string rootKey, string nestedPath) GetRootAndPath(string key, string prefix) + { + var path = key.Substring(prefix.Length); + + // Find the first '.' or '[' to split rootKey and nestedPath + var dotIndex = path.IndexOf('.'); + var bracketIndex = path.IndexOf('['); + + int splitIndex; + if (dotIndex == -1 && bracketIndex == -1) + return (path, ""); + if (dotIndex == -1) + splitIndex = bracketIndex; + else if (bracketIndex == -1) + splitIndex = dotIndex; + else + splitIndex = Math.Min(dotIndex, bracketIndex); + + return (path.Substring(0, splitIndex), path.Substring(splitIndex)); + } + + /// + /// Resolves nested property/array paths from an object. + /// Supports POCOs, ExpandoObject, IDictionary, arrays, lists, and JSON objects. + /// + private object? ResolveNestedValue(object? root, string path) + { + if (root == null || string.IsNullOrWhiteSpace(path)) + return root; + + // Split path into segments, handling array indices + var segments = Regex.Matches(path, @"([^.[]+)|\[(\d+)\]") + .Select(m => m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value) + .ToList(); + + object? current = root; + foreach (var segment in segments) + { + if (current == null) throw new NullReferenceException($"No matching property found for {{{{{segment}}}}}."); + + if (int.TryParse(segment, out int idx)) + { + if (current is Array arr) + { + if (idx < 0 || idx >= arr.Length) throw new IndexOutOfRangeException($"Index {idx} out of range."); + current = arr.GetValue(idx); + } + else if (current is IList list) + { + if (idx < 0 || idx >= list.Count) throw new IndexOutOfRangeException($"Index {idx} out of range."); + current = list[idx]; + } + else if (current is JsonElement jsonElem && jsonElem.ValueKind == JsonValueKind.Array) + { + if (idx < 0 || idx >= jsonElem.GetArrayLength()) throw new IndexOutOfRangeException($"Index {idx} out of range."); + current = jsonElem[idx]; + } + else + { + throw new NullReferenceException($"No matching array or list found for index [{idx}]."); + } + } + else + { + if (current is IDictionary dict) + { + if (!dict.ContainsKey(segment)) throw new KeyNotFoundException($"Key '{segment}' not found."); + current = dict[segment]; + } + else if (current is JsonElement jsonElem) + { + if (jsonElem.ValueKind == JsonValueKind.Object) + { + if (!jsonElem.TryGetProperty(segment, out var prop)) throw new KeyNotFoundException($"Property '{segment}' not found."); + current = prop; + } + else + { + throw new NullReferenceException($"No matching property found for {{{{{segment}}}}}."); + } + } + else + { + var propInfo = current.GetType().GetProperty(segment); + if (propInfo == null) throw new NullReferenceException($"Property '{segment}' not found on type '{current.GetType().Name}'."); + current = propInfo.GetValue(current); + } + } + } + + // Unwrap JsonElement, if needed + if (current is JsonElement elem) + { + switch (elem.ValueKind) + { + case JsonValueKind.String: return elem.GetString(); + case JsonValueKind.Number: return elem.GetDouble(); + case JsonValueKind.True: return true; + case JsonValueKind.False: return false; + case JsonValueKind.Object: + case JsonValueKind.Array: return elem; + case JsonValueKind.Null: return null; + } + } + return current; } } \ No newline at end of file diff --git a/src/modules/sql/README.md b/src/modules/sql/README.md index 5bfcd75e..98ca945e 100644 --- a/src/modules/sql/README.md +++ b/src/modules/sql/README.md @@ -39,6 +39,7 @@ This package extends [Elsa Workflows](https://github.com/elsa-workflows/elsa-cor - Automatic sql parameterization of Variables, Inputs, Outputs and other keywords to help Sql injection. - Add-in approach to each database provider, with the ability to easily integrate other providers using the `ISqlClient` interface. - Syntax highlighting for SQL +- `NEW` (3.6.0) - Support for resolving JSON, POCO's, Objects and ExpandoObjects. --- @@ -161,19 +162,56 @@ SELECT * FROM [Users] WHERE [Name] = @p1 AND [Age] > @p2; ``` -### Supported Expressions +### Expressions -```csharp -{{Workflow.Definition.Id}} -{{Workflow.Definition.Version}} -{{Workflow.Instance.Id}} -{{Correlation.Id}} -{{LastResult}} +You can use Liquid-like expressions to access Input, Output and Variable values in your query. + +```liquid {{Input.}} {{Output.}} {{Variable.}} ``` +Expressions can also access objects with nested properties. The following objects are supported: +- POCOs +- ExpandoObject +- IDictionary +- Arrays +- Lists +- JSON objects + +```liquid +{{Input.MyObjectArr[0]}} +{{Variable.MyObject.User.Id}} +{{Variable.MyObject.Users[3].Name}} +{{Activity.MyObject.Users[2].Age}} +``` + +There is also added support for accessing workflow related properties: +- `ActivityContext`, aliased as `Activity` +- `ExecutionContext`, aliased as `Execution` +- `ExecutionContext.Workflow`, aliased as `Workflow` + +```liquid +{{LastResult}} +{{Workflow.Identity.DefinitionId}} +{{Activity.WorkflowExecutionContext.Id}} +``` + +### Breaking Expression Changes in 3.6.0 + +As the evaluator can now use workflow properties directly, the previous hard coded expression keys have been be removed. +To access the previous key values you will need to update your expressions. + +```liquid +{{Variables.}} --> {{Variable.}} +{{Workflow.Definition.Id}} --> {{Workflow.Identity.DefinitionId}} +{{Workflow.Definition.Version.Id}} --> {{Workflow.Identity.Id}} +{{Workflow.Definition.Version}} --> {{Workflow.Identity.Version}} +{{Workflow.Instance.Id}} --> {{Activity.WorkflowExecutionContext.Id}} +{{Correlation.Id}} --> {{Activity.WorkflowExecutionContext.CorrelationId}} +``` + --- ## 🗺️ Planned Features