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(); 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] [Test]
public async Task I_can_validate_not_empty_for_collections() 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"); 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] [Test]
public async Task I_skip_null_nested_values_for_composed_validators() 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"); 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] [Test]
public async Task I_can_build_property_paths_for_nested_member_access() 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] [Test]
public async Task I_see_it_throw_for_non_member_expressions() public async Task I_see_it_throw_for_non_member_expressions()
{ {
@ -276,4 +338,12 @@ internal sealed class ValidatorTests
member => member.Name, member => member.Name,
rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required.")); 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> /// <returns>The current rule builder instance for method chaining.</returns>
IPropertyRuleBuilder<T, TProperty> WithCode(string code); 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> /// <summary>
/// Sets the severity of the validation rule. /// Sets the severity of the validation rule.
/// </summary> /// </summary>

View file

@ -6,24 +6,16 @@ using System.Linq.Expressions;
namespace Geekeey.Request.Validation; namespace Geekeey.Request.Validation;
internal abstract record Rule 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) protected Rule(Expression expression)
{ {
PropertyPath = GetPropertyPath(expression); PropertyPath = GetPropertyPath(expression);
} }
public PropertyPath PropertyPath { get; } protected PropertyPath GetPropertyPath()
{
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = []; return PropertyPathTransform(PropertyPath);
}
private static PropertyPath GetPropertyPath(Expression expression) private static PropertyPath GetPropertyPath(Expression expression)
{ {
@ -55,6 +47,26 @@ internal abstract record Rule<T, TProperty> : Rule
return string.Join('.', members); 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) public override IEnumerable<Problem> Validate(ValidationContext context)
{ {
if (context.Instance is T instance) if (context.Instance is T instance)
@ -92,7 +104,8 @@ internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
} }
var value = _accessor(instance); 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; var index = 0;
foreach (var element in collection) foreach (var element in collection)
{ {
var propertyPath = PropertyPath + index; var propertyPath = GetPropertyPath() + index;
foreach (var step in Steps) 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."); 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> /// <summary>
/// Adds a rule to ensure that the string property value meets the specified minimum length. /// Adds a rule to ensure that the string property value meets the specified minimum length.
/// </summary> /// </summary>

View file

@ -133,6 +133,15 @@ public abstract class Validator<T> : IValidator<T>
return this; 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) public IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity)
{ {
CurrentRule = CurrentRule with { 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, - **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity,
code, and attempted value. code, and attempted value.
- **Nested validation:** Reuse validators for complex object graphs with `SetValidator`, including DI-based resolution. - **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 ## 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 validator = new CreateUserRequestValidator();
var validation = validator.Validate(new CreateUserRequest( var validation = validator.Validate(new CreateUserRequest(
Name: "", 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 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. 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.