feat: add property path transformation

This resolves #3 to allow custom property paths to be defined.
This commit is contained in:
Louis Seubert 2026-05-22 21:09:22 +02:00
commit a7ecf08efb
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
7 changed files with 167 additions and 14 deletions

View file

@ -46,6 +46,17 @@ internal sealed class RuleBuilderExtensionsTests
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_remove_the_property_path()
{
var validator = new PropertyValidator<StringValueModel, string?>(model =>
model.Value, rule => rule.WithoutPropertyPath().NotEmpty());
var result = validator.Validate(new StringValueModel { Value = " " });
await AssertSingleProblem(result, string.Empty, "Value is required.");
}
[Test]
public async Task I_can_validate_not_empty_for_collections()
{

View file

@ -61,6 +61,22 @@ internal sealed class ValidatorTests
await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Address.Street");
}
[Test]
public async Task I_can_remove_the_parent_property_path_for_nested_validators()
{
var validator = new PropertyValidator<PersonWithAddress, Address?>(
person => person.Address,
rule => rule.WithoutPropertyPath().SetValidator(CreateAddressValidator()));
var result = validator.Validate(new PersonWithAddress
{
Address = new Address { Street = "" },
});
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Street");
}
[Test]
public async Task I_skip_null_nested_values_for_composed_validators()
{
@ -90,6 +106,22 @@ internal sealed class ValidatorTests
await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Members[1].Name");
}
[Test]
public async Task I_can_transform_the_collection_property_path_before_appending_indices()
{
var validator = new CollectionValidator<Team, Member>(
team => team.Members,
rule => rule.WithPropertyPath(static _ => "People").SetValidator(CreateMemberValidator()));
var result = validator.Validate(new Team
{
Members = [new Member { Name = "Ada" }, new Member { Name = "" }],
});
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("People[1].Name");
}
[Test]
public async Task I_can_build_property_paths_for_nested_member_access()
{
@ -129,6 +161,36 @@ internal sealed class ValidatorTests
}
}
[Test]
public async Task I_can_transform_a_property_rule_path()
{
var validator = new PropertyValidator<Person, string?>(
person => person.Name,
rule => rule.WithPropertyPath(static _ => "Username").NotEmpty());
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Username");
}
[Test]
public async Task I_can_compose_parent_and_child_property_path_transforms()
{
var validator = new PropertyValidator<PersonWithAddress, Address?>(
person => person.Address,
rule => rule.WithPropertyPath(static _ => "Location")
.SetValidator(CreateRenamedAddressValidator()));
var result = validator.Validate(new PersonWithAddress
{
Address = new Address { Street = "" },
});
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Location.Road");
}
[Test]
public async Task I_see_it_throw_for_non_member_expressions()
{
@ -276,4 +338,12 @@ internal sealed class ValidatorTests
member => member.Name,
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required."));
}
private static PropertyValidator<Address, string?> CreateRenamedAddressValidator()
{
return new PropertyValidator<Address, string?>(
address => address.Street,
rule => rule.WithPropertyPath(static _ => "Road")
.Must(street => !string.IsNullOrWhiteSpace(street), "Street is required."));
}
}

View file

@ -39,6 +39,13 @@ public interface IPropertyRuleBuilder<T, out TProperty>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> WithCode(string code);
/// <summary>
/// Transforms the property path reported by the validation rule.
/// </summary>
/// <param name="transform">The function used to transform the rule property path.</param>
/// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> WithPropertyPath(Func<PropertyPath, PropertyPath> transform);
/// <summary>
/// Sets the severity of the validation rule.
/// </summary>

View file

@ -6,24 +6,16 @@ using System.Linq.Expressions;
namespace Geekeey.Request.Validation;
internal abstract record Rule
{
public Severity Severity { get; init; } = Severity.Error;
public string? Code { get; init; }
public abstract IEnumerable<Problem> Validate(ValidationContext context);
}
internal abstract record Rule<T, TProperty> : Rule
{
protected Rule(Expression expression)
{
PropertyPath = GetPropertyPath(expression);
}
public PropertyPath PropertyPath { get; }
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
protected PropertyPath GetPropertyPath()
{
return PropertyPathTransform(PropertyPath);
}
private static PropertyPath GetPropertyPath(Expression expression)
{
@ -55,6 +47,26 @@ internal abstract record Rule<T, TProperty> : Rule
return string.Join('.', members);
}
public PropertyPath PropertyPath { get; }
public Severity Severity { get; init; } = Severity.Error;
public string? Code { get; init; }
public Func<PropertyPath, PropertyPath> PropertyPathTransform { get; init; } = static path => path;
public abstract IEnumerable<Problem> Validate(ValidationContext context);
}
internal abstract record Rule<T, TProperty> : Rule
{
protected Rule(Expression expression)
: base(expression)
{
}
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
public override IEnumerable<Problem> Validate(ValidationContext context)
{
if (context.Instance is T instance)
@ -92,7 +104,8 @@ internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
}
var value = _accessor(instance);
return Steps.SelectMany(step => step.Validate(value, context, PropertyPath, Code, Severity));
var propertyPath = GetPropertyPath();
return Steps.SelectMany(step => step.Validate(value, context, propertyPath, Code, Severity));
}
}
@ -120,7 +133,7 @@ internal sealed record CollectionRule<T, TElement> : Rule<T, TElement>
var index = 0;
foreach (var element in collection)
{
var propertyPath = PropertyPath + index;
var propertyPath = GetPropertyPath() + index;
foreach (var step in Steps)
{

View file

@ -77,6 +77,21 @@ public static class RuleBuilderExtensions
return rule.Must(static value => value is not null && value.Any(), "Value is required.");
}
/// <summary>
/// Removes the current rule property path from emitted validation problems.
/// </summary>
/// <typeparam name="T">The type of the object being validated.</typeparam>
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
/// <param name="rule">The rule builder to which the property path transform is applied.</param>
/// <returns>The updated rule builder with the property path removed.</returns>
public static IPropertyRuleBuilder<T, TProperty> WithoutPropertyPath<T, TProperty>(
this IPropertyRuleBuilder<T, TProperty> rule)
{
ArgumentNullException.ThrowIfNull(rule);
return rule.WithPropertyPath(static _ => string.Empty);
}
/// <summary>
/// Adds a rule to ensure that the string property value meets the specified minimum length.
/// </summary>

View file

@ -133,6 +133,15 @@ public abstract class Validator<T> : IValidator<T>
return this;
}
public IPropertyRuleBuilder<T, TProperty> WithPropertyPath(Func<PropertyPath, PropertyPath> transform)
{
ArgumentNullException.ThrowIfNull(transform);
CurrentRule = CurrentRule with { PropertyPathTransform = transform };
return this;
}
public IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity)
{
CurrentRule = CurrentRule with { Severity = severity };

View file

@ -7,6 +7,8 @@
- **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity,
code, and attempted value.
- **Nested validation:** Reuse validators for complex object graphs with `SetValidator`, including DI-based resolution.
- **Configurable paths:** Rewrite or remove rule property paths when the reported path should differ from the CLR
member path.
## Getting Started
@ -65,6 +67,29 @@ public sealed class CreateUserRequestValidator : Validator<CreateUserRequest>
}
}
public sealed record LoginInput(string? Username);
public sealed record LoginRequest(LoginInput Input);
public sealed class LoginInputValidator : Validator<LoginInput>
{
public LoginInputValidator()
{
RuleFor(input => input.Username)
.NotEmpty();
}
}
public sealed class LoginRequestValidator : Validator<LoginRequest>
{
public LoginRequestValidator()
{
RuleFor(request => request.Input)
.WithoutPropertyPath()
.SetValidator(new LoginInputValidator());
}
}
var validator = new CreateUserRequestValidator();
var validation = validator.Validate(new CreateUserRequest(
Name: "",
@ -80,3 +105,6 @@ foreach (var problem in validation.Problems)
The resulting `Problem` entries include full property paths like `Address.Street` and `Tags[0]`, making it easy to
surface validation errors back to callers or APIs.
If the validation path needs to differ from the CLR property path, use `WithPropertyPath(...)` for a custom transform
or `WithoutPropertyPath()` to drop the current rule path entirely before nested paths are appended.