// Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 using System.Collections; using System.Linq.Expressions; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace Geekeey.Request.Validation; /// /// Represents a property path used by validation problems. /// [JsonConverter(typeof(PropertyPathJsonConverter))] public readonly struct PropertyPath : IEquatable, IEquatable, IReadOnlyList { /// /// Creates a new property path. /// public PropertyPath(string value) { ArgumentNullException.ThrowIfNull(value); Value = value; } /// /// Creates a property path from a lambda expression. /// /// The type containing the property. /// The expression representing the property access. /// A new . public static PropertyPath Of(Expression> expression) { ArgumentNullException.ThrowIfNull(expression); return FromExpression(expression); } internal static PropertyPath FromExpression(Expression expression) { var segments = new List(); var current = expression; while (current is not null) { switch (current) { case LambdaExpression expr: current = expr.Body; break; case MemberExpression expr: segments.Add(expr.Member.Name); current = expr.Expression; break; case MethodCallExpression { Method.Name: "get_Item", Arguments.Count: 1 } expr: segments.Add($"[{Evaluate(expr.Arguments[0])}]"); current = expr.Object; break; case BinaryExpression { NodeType: ExpressionType.ArrayIndex } expr: segments.Add($"[{Evaluate(expr.Right)}]"); current = expr.Left; break; case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } expr: current = expr.Operand; break; case ParameterExpression: current = null; break; default: throw new InvalidOperationException("Only simple member access expressions are supported."); } } var builder = new StringBuilder(); for (var i = segments.Count - 1; i >= 0; i--) { var segment = segments[i]; if (builder.Length > 0 && !segment.StartsWith('[')) { builder.Append('.'); } builder.Append(segment); } return builder.ToString(); } private static object? Evaluate(Expression expression) { if (expression is ConstantExpression constant) { return constant.Value; } if (expression is MemberExpression member && member.Expression is ConstantExpression closure) { var value = closure.Value; if (member.Member is System.Reflection.FieldInfo field) { return field.GetValue(value); } if (member.Member is System.Reflection.PropertyInfo prop) { return prop.GetValue(value); } } return Expression.Lambda(expression).Compile().DynamicInvoke(); } /// /// Gets the raw property path. /// public string Value { get; } = string.Empty; /// /// Gets the number of path segments. /// public int Count { get { var count = 0; var enumerator = new Enumerator(Value); while (enumerator.MoveNext()) { count++; } return count; } } /// /// Gets whether the property path contains no segments. /// public bool IsEmpty => Value.Length is 0; /// /// Gets the segment at the specified index. /// public ReadOnlySpan this[int index] { get { ArgumentOutOfRangeException.ThrowIfNegative(index); var currentIndex = 0; var enumerator = new Enumerator(Value); while (enumerator.MoveNext()) { if (currentIndex == index) { return enumerator.Current; } currentIndex++; } throw new ArgumentOutOfRangeException(nameof(index), index, "The segment index must refer to an existing property path segment."); } } /// public override string ToString() { return Value; } /// /// Converts a string into a property path. /// public static implicit operator PropertyPath(string value) { return new PropertyPath(value); } /// /// Converts a property path into its string representation. /// public static implicit operator string(PropertyPath propertyPath) { return propertyPath.Value; } /// public bool Equals(PropertyPath other) { return string.Equals(Value, other.Value, StringComparison.Ordinal); } /// public bool Equals(string? other) { return string.Equals(Value, other, StringComparison.Ordinal); } /// public override bool Equals(object? obj) { return (obj is PropertyPath other && Equals(other)) || (obj is string s && Equals(s)); } /// public override int GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); } /// /// Compares two property paths for ordinal equality. /// public static bool operator ==(PropertyPath left, PropertyPath right) { return left.Equals(right); } /// /// Compares two property paths for ordinal inequality. /// public static bool operator !=(PropertyPath left, PropertyPath right) { return !left.Equals(right); } /// /// Combines two property paths using the same rules as nested validation paths. /// public static PropertyPath operator +(PropertyPath prefix, PropertyPath suffix) { if (prefix.Value is not { Length: > 0 }) { return suffix; } if (suffix.Value is not { Length: > 0 }) { return prefix; } return $"{prefix}.{suffix}"; } /// /// Appends a collection index to a property path. /// public static PropertyPath operator +(PropertyPath propertyPath, int index) { return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]"; } /// /// Returns an allocation-free enumerator over the path segments. /// public Enumerator GetEnumerator() { return new Enumerator(Value); } /// string IReadOnlyList.this[int index] => this[index].ToString(); /// IEnumerator IEnumerable.GetEnumerator() { var enumerator = new Enumerator(Value); List segments = []; while (enumerator.MoveNext()) { segments.Add(enumerator.Current.ToString()); } return segments.GetEnumerator(); } /// IEnumerator IEnumerable.GetEnumerator() { var enumerator = new Enumerator(Value); List segments = []; while (enumerator.MoveNext()) { segments.Add(enumerator.Current.ToString()); } return segments.GetEnumerator(); } internal string ToJsonName(JsonNamingPolicy? namingPolicy) { var value = Value; if (namingPolicy is null || value.Length is 0) { return value; } var builder = new StringBuilder(value.Length); var first = true; var enumerator = new Enumerator(value); while (enumerator.MoveNext()) { if (!first) { builder.Append('.'); } if (enumerator.Current is { IsEmpty: false } segment) { if (segment.IndexOf('[') is >= 0 and var x) { builder.Append(namingPolicy.ConvertName(segment[..x].ToString())); builder.Append(segment[x..]); } else { builder.Append(namingPolicy.ConvertName(segment.ToString())); } } first = false; } return builder.ToString(); } /// /// Enumerates path segments without allocations. /// public ref struct Enumerator { private readonly ReadOnlySpan _value; private int _nextStart; internal Enumerator(ReadOnlySpan value) { _value = value; _nextStart = value.Length is 0 ? -1 : 0; Current = default; } /// /// Gets the current path segment. /// public ReadOnlySpan Current { get; private set; } /// /// Advances to the next path segment. /// public bool MoveNext() { if (_nextStart < 0) { return false; } var start = _nextStart; var bracketDepth = 0; for (var i = start; i < _value.Length; i++) { switch (_value[i]) { case '[': bracketDepth++; break; case ']': bracketDepth = int.Max(bracketDepth - 1, 0); break; case '.' when bracketDepth is 0: Current = _value[start..i]; _nextStart = i + 1; return true; default: break; } } Current = _value[start..]; _nextStart = -1; return true; } } } internal sealed class PropertyPathJsonConverter : JsonConverter { /// public override PropertyPath Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.String) { return new PropertyPath(reader.GetString()!); } throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); } /// public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); } }