-
-
Notifications
You must be signed in to change notification settings - Fork 9
Enhancement: PathResolver.FindField — reflection-based fallback for nullable nested properties #95
Description
Problem
PathResolver.FindField returns null when a property in the navigation path is null (confirmed by the existing test FindField_WithNullObjectInPath_ReturnsNull in PathResolverTests.cs). The fallback in FluentValidator.ApplyValidationResults then creates:
var field = PathResolver.FindField(model, validationFailure.PropertyName)
?? new FieldIdentifier(model, validationFailure.PropertyName);This produces FieldIdentifier(rootModel, "Address.City"), which doesn't match what Blazor's <ValidationMessage For="() => model.Address.City"> generates: FieldIdentifier(addressInstance, "City").
The result: Validation messages for nullable nested properties never display inline, because the FieldIdentifier from the fallback doesn't match the one from the <ValidationMessage> expression.
When this occurs
This is common with NotNull() / NotEmpty() rules on nested objects — the property is null because the validation fired. Using the sample models from this repo:
public record Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public Address? Address { get; set; } // null = not yet provided
}
public record Address
{
public string? City { get; set; }
}
// Validator
public class PersonValidator : AbstractValidator<Person>
{
public PersonValidator()
{
RuleFor(x => x.Address).NotNull().WithMessage("Address is required");
// Or with a child validator:
RuleFor(x => x.Address!.City).NotEmpty().WithMessage("City is required")
.When(x => x.Address is not null);
}
}For RuleFor(x => x.Address).NotNull(): FluentValidation produces PropertyName = "Address". This is a direct property on the root — FindField handles this correctly.
But for nested validators or child properties where the parent is null:
public record Company
{
public string? Name { get; set; }
public List<Employee> Employees { get; set; } = [];
}
public record Employee
{
public string? FirstName { get; set; }
public Address? HomeAddress { get; set; } // null when not provided
}
// Validator with RuleForEach
RuleForEach(x => x.Employees).ChildRules(employee =>
{
employee.RuleFor(e => e.HomeAddress).NotNull();
});FluentValidation produces PropertyName = "Employees[0].HomeAddress". FindField navigates to Company → Employees[0] → tries HomeAddress — which is null. Returns null. The fallback FieldIdentifier(rootModel, "Employees[0].HomeAddress") doesn't match <ValidationMessage For="() => employee.HomeAddress"> which creates FieldIdentifier(employeeInstance, "HomeAddress").
The same issue applies to any nullable nested property in the path — the deeper the nesting, the more likely a null is encountered.
How I resolved it
Instead of relying solely on the live object instance graph, I walk the property path and stop at the last non-null parent when a null is encountered. This matches what Blazor's expression tree parsing does for <ValidationMessage For="() => parent.Property">.
The key insight: for "Employees[0].HomeAddress", we don't need HomeAddress's value to create the correct FieldIdentifier. We just need:
- The parent object:
company.Employees[0](non-null) - The property name:
"HomeAddress"
This produces FieldIdentifier(employeeInstance, "HomeAddress") — matching the <ValidationMessage>.
Here is the core approach (simplified):
public static FieldIdentifier ResolveFieldIdentifier(object rootModel, string propertyPath)
{
var currentObject = rootModel;
var remainingPath = propertyPath.AsSpan();
while (remainingPath.Length > 0)
{
var dotIndex = remainingPath.IndexOf('.');
var bracketIndex = remainingPath.IndexOf('[');
// No more separators — this is the final field name
if (dotIndex < 0 && bracketIndex < 0)
return new FieldIdentifier(currentObject, remainingPath.ToString());
int nextSep = (dotIndex >= 0 && bracketIndex >= 0)
? Math.Min(dotIndex, bracketIndex)
: (dotIndex >= 0 ? dotIndex : bracketIndex);
if (nextSep > 0)
{
var propertyName = remainingPath[..nextSep].ToString();
var navigatedObject = currentObject.GetType()
.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)
?.GetValue(currentObject);
// When null, use the CURRENT object as the model
// with the REMAINING path as the field name.
// This matches what <ValidationMessage For="() => parent.Property"> creates.
if (navigatedObject is null)
return new FieldIdentifier(currentObject, remainingPath.ToString());
currentObject = navigatedObject;
remainingPath = remainingPath[nextSep..];
continue;
}
// Handle '.' separator and '[n]' indexers...
}
return new FieldIdentifier(rootModel, propertyPath);
}Suggestion
This could be an opt-in enhancement to FindField or an alternative resolution strategy. Since reflection has performance implications in Blazor WASM, it should probably not be the default — but as a configurable fallback it would save developers from building their own workaround.
Possible API approaches (just ideas — happy to defer to your design preference):
- A parameter on
FluentValidator(e.g.,ResolveNullPaths="true") - An enhancement to
FindFieldthat falls back to reflection-based navigation when the instance graph has nulls - A configurable
IFieldResolverstrategy
Happy to share more detail about my implementation if useful.
Environment
- Blazilla v2.3.0
- .NET 10.0 Blazor WASM
- FluentValidation with nested validators and
RuleForEach