158 lines
5.6 KiB
C#
158 lines
5.6 KiB
C#
using System.Globalization;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace w4c_workflows.Services.Nodes;
|
|
|
|
/// <summary>One step in a resolved item path: a property name or an array index.</summary>
|
|
internal readonly record struct NodePathSegment(string? Name, int Index)
|
|
{
|
|
public bool IsIndex => Name is null;
|
|
|
|
public static NodePathSegment Property(string name) => new(name, 0);
|
|
public static NodePathSegment ElementAt(int index) => new(null, index);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The single resolver for "@item" style paths, shared by the expression
|
|
/// interpolator and the executor field readers (sort / dedup / aggregate /
|
|
/// HTTP output paths). Supports <c>.name</c>, <c>["name"]</c> and <c>[index]</c>,
|
|
/// so <c>items[0].id</c> resolves everywhere; before the merge the executors'
|
|
/// resolver silently failed on array indices.
|
|
///
|
|
/// Parsing and navigation are separated so the interpolator can turn a parse
|
|
/// error into a typed exception while the executor readers treat it as a miss.
|
|
/// </summary>
|
|
internal static class NodePath
|
|
{
|
|
/// <summary>
|
|
/// Resolves a path against <paramref name="root"/> and returns the node, or
|
|
/// null when the path is empty, malformed or absent. Never throws.
|
|
/// <paramref name="dotNotation"/> false treats the whole path as one literal
|
|
/// property name (a field name that may itself contain dots).
|
|
/// </summary>
|
|
public static JsonNode? Read(JsonNode? root, string? path, bool dotNotation)
|
|
{
|
|
if (string.IsNullOrEmpty(path))
|
|
return null;
|
|
|
|
if (!dotNotation)
|
|
return root is JsonObject literal ? Property(literal, path) : null;
|
|
|
|
return TryParse(path, out var segments, out _) ? Navigate(root, segments) : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a path into segments. Returns false with a human-readable
|
|
/// <paramref name="error"/> when the syntax is invalid.
|
|
/// </summary>
|
|
public static bool TryParse(string path, out IReadOnlyList<NodePathSegment> segments, out string? error)
|
|
{
|
|
var parsed = new List<NodePathSegment>();
|
|
var cursor = 0;
|
|
var first = true;
|
|
error = null;
|
|
|
|
while (cursor < path.Length)
|
|
{
|
|
var ch = path[cursor];
|
|
|
|
if (ch == '.')
|
|
{
|
|
cursor++;
|
|
var name = ReadIdentifier(path, ref cursor);
|
|
if (name.Length == 0)
|
|
{
|
|
error = $"expected a field name after '.' at {cursor}";
|
|
segments = Array.Empty<NodePathSegment>();
|
|
return false;
|
|
}
|
|
|
|
parsed.Add(NodePathSegment.Property(name));
|
|
}
|
|
else if (ch == '[')
|
|
{
|
|
cursor++;
|
|
var close = path.IndexOf(']', cursor);
|
|
if (close < 0)
|
|
{
|
|
error = "unterminated '[' in path";
|
|
segments = Array.Empty<NodePathSegment>();
|
|
return false;
|
|
}
|
|
|
|
var token = path[cursor..close].Trim();
|
|
cursor = close + 1;
|
|
|
|
if (token.Length >= 2 && (token[0] == '"' || token[0] == '\'') && token[^1] == token[0])
|
|
{
|
|
parsed.Add(NodePathSegment.Property(token[1..^1]));
|
|
}
|
|
else if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) && index >= 0)
|
|
{
|
|
parsed.Add(NodePathSegment.ElementAt(index));
|
|
}
|
|
else
|
|
{
|
|
error = $"invalid array index '{token}'";
|
|
segments = Array.Empty<NodePathSegment>();
|
|
return false;
|
|
}
|
|
}
|
|
else if (first)
|
|
{
|
|
// A leading bare identifier: executor paths look like "user.name",
|
|
// not ".user.name" as the interpolator's tail does.
|
|
var name = ReadIdentifier(path, ref cursor);
|
|
if (name.Length == 0)
|
|
{
|
|
error = $"unexpected '{ch}' in path";
|
|
segments = Array.Empty<NodePathSegment>();
|
|
return false;
|
|
}
|
|
|
|
parsed.Add(NodePathSegment.Property(name));
|
|
}
|
|
else
|
|
{
|
|
error = $"unexpected '{ch}' in path";
|
|
segments = Array.Empty<NodePathSegment>();
|
|
return false;
|
|
}
|
|
|
|
first = false;
|
|
}
|
|
|
|
segments = parsed;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Walks parsed segments; a missing property or out-of-range index yields null.</summary>
|
|
public static JsonNode? Navigate(JsonNode? root, IReadOnlyList<NodePathSegment> segments)
|
|
{
|
|
var current = root;
|
|
|
|
foreach (var segment in segments)
|
|
{
|
|
if (current == null)
|
|
return null;
|
|
|
|
current = segment.IsIndex
|
|
? current is JsonArray array && segment.Index < array.Count ? array[segment.Index] : null
|
|
: current is JsonObject obj ? Property(obj, segment.Name!) : null;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private static JsonNode? Property(JsonObject obj, string name)
|
|
=> obj.TryGetPropertyValue(name, out var value) ? value : null;
|
|
|
|
private static string ReadIdentifier(string path, ref int cursor)
|
|
{
|
|
var start = cursor;
|
|
while (cursor < path.Length && (char.IsLetterOrDigit(path[cursor]) || path[cursor] == '_' || path[cursor] == '-'))
|
|
cursor++;
|
|
return path[start..cursor];
|
|
}
|
|
}
|