407 lines
9.1 KiB
C#
407 lines
9.1 KiB
C#
|
|
// 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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Represents a property path used by validation problems.
|
||
|
|
/// </summary>
|
||
|
|
[JsonConverter(typeof(PropertyPathJsonConverter))]
|
||
|
|
public readonly struct PropertyPath : IEquatable<PropertyPath>, IEquatable<string>, IReadOnlyList<string>
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// Creates a new property path.
|
||
|
|
/// </summary>
|
||
|
|
public PropertyPath(string value)
|
||
|
|
{
|
||
|
|
ArgumentNullException.ThrowIfNull(value);
|
||
|
|
Value = value;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Creates a property path from a lambda expression.
|
||
|
|
/// </summary>
|
||
|
|
/// <typeparam name="T">The type containing the property.</typeparam>
|
||
|
|
/// <param name="expression">The expression representing the property access.</param>
|
||
|
|
/// <returns>A new <see cref="PropertyPath"/>.</returns>
|
||
|
|
public static PropertyPath Of<T>(Expression<Func<T, object?>> expression)
|
||
|
|
{
|
||
|
|
ArgumentNullException.ThrowIfNull(expression);
|
||
|
|
return FromExpression(expression);
|
||
|
|
}
|
||
|
|
|
||
|
|
internal static PropertyPath FromExpression(Expression expression)
|
||
|
|
{
|
||
|
|
var segments = new List<string>();
|
||
|
|
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Gets the raw property path.
|
||
|
|
/// </summary>
|
||
|
|
public string Value { get; } = string.Empty;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Gets the number of path segments.
|
||
|
|
/// </summary>
|
||
|
|
public int Count
|
||
|
|
{
|
||
|
|
get
|
||
|
|
{
|
||
|
|
var count = 0;
|
||
|
|
var enumerator = new Enumerator(Value);
|
||
|
|
while (enumerator.MoveNext())
|
||
|
|
{
|
||
|
|
count++;
|
||
|
|
}
|
||
|
|
|
||
|
|
return count;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Gets whether the property path contains no segments.
|
||
|
|
/// </summary>
|
||
|
|
public bool IsEmpty => Value.Length is 0;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Gets the segment at the specified index.
|
||
|
|
/// </summary>
|
||
|
|
public ReadOnlySpan<char> 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.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
public override string ToString()
|
||
|
|
{
|
||
|
|
return Value;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Converts a string into a property path.
|
||
|
|
/// </summary>
|
||
|
|
public static implicit operator PropertyPath(string value)
|
||
|
|
{
|
||
|
|
return new PropertyPath(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Converts a property path into its string representation.
|
||
|
|
/// </summary>
|
||
|
|
public static implicit operator string(PropertyPath propertyPath)
|
||
|
|
{
|
||
|
|
return propertyPath.Value;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
public bool Equals(PropertyPath other)
|
||
|
|
{
|
||
|
|
return string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
public bool Equals(string? other)
|
||
|
|
{
|
||
|
|
return string.Equals(Value, other, StringComparison.Ordinal);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
public override bool Equals(object? obj)
|
||
|
|
{
|
||
|
|
return (obj is PropertyPath other && Equals(other)) || (obj is string s && Equals(s));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
public override int GetHashCode()
|
||
|
|
{
|
||
|
|
return StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Compares two property paths for ordinal equality.
|
||
|
|
/// </summary>
|
||
|
|
public static bool operator ==(PropertyPath left, PropertyPath right)
|
||
|
|
{
|
||
|
|
return left.Equals(right);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Compares two property paths for ordinal inequality.
|
||
|
|
/// </summary>
|
||
|
|
public static bool operator !=(PropertyPath left, PropertyPath right)
|
||
|
|
{
|
||
|
|
return !left.Equals(right);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Combines two property paths using the same rules as nested validation paths.
|
||
|
|
/// </summary>
|
||
|
|
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}";
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Appends a collection index to a property path.
|
||
|
|
/// </summary>
|
||
|
|
public static PropertyPath operator +(PropertyPath propertyPath, int index)
|
||
|
|
{
|
||
|
|
return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]";
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Returns an allocation-free enumerator over the path segments.
|
||
|
|
/// </summary>
|
||
|
|
public Enumerator GetEnumerator()
|
||
|
|
{
|
||
|
|
return new Enumerator(Value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
string IReadOnlyList<string>.this[int index] => this[index].ToString();
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
IEnumerator<string> IEnumerable<string>.GetEnumerator()
|
||
|
|
{
|
||
|
|
var enumerator = new Enumerator(Value);
|
||
|
|
|
||
|
|
List<string> segments = [];
|
||
|
|
while (enumerator.MoveNext())
|
||
|
|
{
|
||
|
|
segments.Add(enumerator.Current.ToString());
|
||
|
|
}
|
||
|
|
|
||
|
|
return segments.GetEnumerator();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
IEnumerator IEnumerable.GetEnumerator()
|
||
|
|
{
|
||
|
|
var enumerator = new Enumerator(Value);
|
||
|
|
|
||
|
|
List<string> 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();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Enumerates path segments without allocations.
|
||
|
|
/// </summary>
|
||
|
|
public ref struct Enumerator
|
||
|
|
{
|
||
|
|
private readonly ReadOnlySpan<char> _value;
|
||
|
|
private int _nextStart;
|
||
|
|
|
||
|
|
internal Enumerator(ReadOnlySpan<char> value)
|
||
|
|
{
|
||
|
|
_value = value;
|
||
|
|
_nextStart = value.Length is 0 ? -1 : 0;
|
||
|
|
Current = default;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Gets the current path segment.
|
||
|
|
/// </summary>
|
||
|
|
public ReadOnlySpan<char> Current { get; private set; }
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Advances to the next path segment.
|
||
|
|
/// </summary>
|
||
|
|
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<PropertyPath>
|
||
|
|
{
|
||
|
|
/// <inheritdoc />
|
||
|
|
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}.");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <inheritdoc />
|
||
|
|
public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options)
|
||
|
|
{
|
||
|
|
writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy));
|
||
|
|
}
|
||
|
|
}
|