feat: add property path support

This commit is contained in:
Louis Seubert 2026-05-16 11:38:17 +02:00
commit 8f54ff4e31
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
8 changed files with 276 additions and 49 deletions

View file

@ -11,7 +11,7 @@ public record Problem
/// <summary>
/// The name of the property.
/// </summary>
public required string PropertyName { get; init; }
public required PropertyPath PropertyPath { get; init; }
/// <summary>
/// Custom severity level associated with the failure.

View file

@ -0,0 +1,169 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
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>
{
/// <summary>
/// Creates a new property path.
/// </summary>
public PropertyPath(string value)
{
ArgumentNullException.ThrowIfNull(value);
Value = value;
}
/// <summary>
/// Gets the raw property path.
/// </summary>
public string Value { get; } = string.Empty;
/// <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 override bool Equals(object? obj)
{
return obj is PropertyPath other && Equals(other);
}
/// <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}]";
}
internal string ToJsonName(JsonNamingPolicy? namingPolicy)
{
var value = Value ?? string.Empty;
if (namingPolicy is null || value.Length is 0)
{
return value;
}
var builder = new StringBuilder(value.Length);
var first = true;
foreach (var range in value.AsSpan().Split('.'))
{
if (!first)
{
builder.Append('.');
}
var segment = value.AsSpan()[range];
if (segment.Length is not 0)
{
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();
}
}
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));
}
}

View file

@ -18,14 +18,14 @@ internal abstract record Rule<T, TProperty> : Rule
{
protected Rule(Expression expression)
{
PropertyName = GetPropertyPath(expression);
PropertyPath = GetPropertyPath(expression);
}
public string PropertyName { get; }
public PropertyPath PropertyPath { get; }
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
private static string GetPropertyPath(Expression expression)
private static PropertyPath GetPropertyPath(Expression expression)
{
var members = new Stack<string>();
@ -34,18 +34,15 @@ internal abstract record Rule<T, TProperty> : Rule
{
switch (current)
{
case LambdaExpression lambdaExpression:
current = lambdaExpression.Body;
case LambdaExpression expr:
current = expr.Body;
break;
case MemberExpression memberExpression:
members.Push(memberExpression.Member.Name);
current = memberExpression.Expression;
case MemberExpression expr:
members.Push(expr.Member.Name);
current = expr.Expression;
break;
case UnaryExpression
{
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked
} unaryExpression:
current = unaryExpression.Operand;
case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } expr:
current = expr.Operand;
break;
case ParameterExpression:
current = null;
@ -55,7 +52,7 @@ internal abstract record Rule<T, TProperty> : Rule
}
}
return string.Join(".", members);
return string.Join('.', members);
}
public override IEnumerable<Problem> Validate(ValidationContext context)
@ -95,7 +92,7 @@ internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
}
var value = _accessor(instance);
return Steps.SelectMany(step => step.Validate(value, context, PropertyName, Code, Severity));
return Steps.SelectMany(step => step.Validate(value, context, PropertyPath, Code, Severity));
}
}
@ -123,11 +120,11 @@ internal sealed record CollectionRule<T, TElement> : Rule<T, TElement>
var index = 0;
foreach (var element in collection)
{
var propertyName = string.IsNullOrEmpty(PropertyName) ? $"[{index}]" : $"{PropertyName}[{index}]";
var propertyPath = PropertyPath + index;
foreach (var step in Steps)
{
foreach (var problem in step.Validate(element, context, propertyName, Code, Severity))
foreach (var problem in step.Validate(element, context, propertyPath, Code, Severity))
{
yield return problem;
}

View file

@ -5,7 +5,7 @@ namespace Geekeey.Request.Validation;
internal interface IRuleStep<in TValue>
{
IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity);
IEnumerable<Problem> Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity);
}
internal sealed record PredicateRuleStep<TValue> : IRuleStep<TValue>
@ -19,7 +19,7 @@ internal sealed record PredicateRuleStep<TValue> : IRuleStep<TValue>
_message = message;
}
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity)
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity)
{
if (_predicate(value))
{
@ -28,7 +28,7 @@ internal sealed record PredicateRuleStep<TValue> : IRuleStep<TValue>
yield return new Problem
{
PropertyName = propertyPath,
PropertyPath = propertyPath,
Message = _message,
Code = code,
Severity = severity,
@ -46,7 +46,7 @@ internal sealed record ValidatorRuleStep<TValue> : IRuleStep<TValue>
_resolver = resolver;
}
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity)
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity)
{
if (value is null)
{
@ -58,22 +58,7 @@ internal sealed record ValidatorRuleStep<TValue> : IRuleStep<TValue>
foreach (var problem in validation.Problems)
{
yield return problem with { PropertyName = AppendPropertyPath(propertyPath, problem.PropertyName) };
yield return problem with { PropertyPath = propertyPath + problem.PropertyPath };
}
}
private static string AppendPropertyPath(string prefix, string suffix)
{
if (string.IsNullOrEmpty(prefix))
{
return suffix;
}
if (string.IsNullOrEmpty(suffix))
{
return prefix;
}
return $"{prefix}.{suffix}";
}
}

View file

@ -77,7 +77,7 @@ var validation = validator.Validate(new CreateUserRequest(
foreach (var problem in validation.Problems)
{
Console.WriteLine($"{problem.PropertyName}: {problem.Message}");
Console.WriteLine($"{problem.PropertyPath}: {problem.Message}");
}
```