Search code examples
c#wpfvalidationchainingfluentvalidation

FluentValidation Chain Properties Validation Issue


I've just implemented INotifyDataErrorInfo using JeremySkinner's FluentValidation. However I have some difficulties with validation of complex properties.

For example, I would like to validate Nationality property:

RuleFor(vm => vm.Nationality.SelectedItem.Value)
  .NotEmpty()
  .Length(0, 255);

However, this great looking peace of code has two major problems:

1) it throws null reference exception when SelectedItem is null.

it would be great if I could write something like this:

CustomizedRuleFor(vm => vm.Nationality.SelectedItem.Value)
   .NotEmpty(); //add some stuff here

2) full property path in error message, e.g: "The specified condition was not met for 'Nationality. Selected Item. Value'". I only need 'Nationality' in error message.

I know I can override error message using WithMessage extension method, but don't want to do it for every validation rule.

Do you have any suggestions? Thanks


Solution

  • Problem 1.

    You can solve getting NullReferenceException problem by two ways, which depends on necessarity of client validation support and availablity to change model class:

    Modify your model's default constructor to create SelectedItem with null value:

    public class Nationality
    {
        public Nationality()
        {
            // use proper class instead of SelectableItem 
            SelectedItem = new SelectableItem { Value = null };
        }
    }
    

    Or you can use conditional validation instead, if SelectedItem should be null in different cases and it's normal situation for you:

    RuleFor(vm => vm.Nationality.SelectedItem.Value)
        .When(vm => vm.Nationality.SelectedItem != null)
        .NotEmpty()
        .Length(0, 255);
    

    In this case validator will validate only when condition is true, but conditional validation doesn't support client-side validation (if you want to integrate with ASP.NET MVC).

    Problem 2.

    To save default error message format, add WithName method to rule builder method chain:

    RuleFor(vm => vm.Nationality.SelectedItem.Value)
        .WithName("Nationality") // replace "Nationality.SelectedItem.Value" string with "Nationality" in error messages for both rules
        .NotEmpty()
        .Length(0, 255);
    

    UPDATE: GENERIC SOLUTION

    Extension method for rule builder

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Linq.Expressions;
    using FluentValidation;
    using FluentValidation.Attributes;
    using FluentValidation.Internal;
    
    
    public static class FluentValidationExtensions
    {
        public static IRuleBuilderOptions<TModel, TProperty> ApplyChainValidation<TModel, TProperty>(this IRuleBuilderOptions<TModel, TProperty> builder, Expression<Func<TModel, TProperty>> expr)
        {
            // with name string
            var firstMember = PropertyChain.FromExpression(expr).ToString().Split('.')[0]; // PropertyChain is internal FluentValidation class
    
            // create stack to collect model properties from property chain since parents to childs to check for null in appropriate order
            var reversedExpressions = new Stack<Expression>();
    
            var getMemberExp = new Func<Expression, MemberExpression>(toUnwrap =>
            {
                if (toUnwrap is UnaryExpression)
                {
                    return ((UnaryExpression)toUnwrap).Operand as MemberExpression;
                }
    
                return toUnwrap as MemberExpression;
            }); // lambda from PropertyChain implementation
    
            var memberExp = getMemberExp(expr.Body);
            var firstSkipped = false;
    
            // check only parents of property to validate
            while (memberExp != null)
            {
                if (firstSkipped)
                {
                    reversedExpressions.Push(memberExp); // don't check target property for null
                }
                firstSkipped = true;
                memberExp = getMemberExp(memberExp.Expression);
            }
    
            // build expression that check parent properties for null
            var currentExpr = reversedExpressions.Pop();
            var whenExpr = Expression.NotEqual(currentExpr, Expression.Constant(null));
            while (reversedExpressions.Count > 0)
            {
                whenExpr = Expression.AndAlso(whenExpr, Expression.NotEqual(currentExpr, Expression.Constant(null)));
                currentExpr = reversedExpressions.Pop();
            }
    
            var parameter = expr.Parameters.First();
            var lambda = Expression.Lambda<Func<TModel, bool>>(whenExpr, parameter); // use parameter of source expression
            var compiled = lambda.Compile();
    
            return builder
              .WithName(firstMember)
              .When(model => compiled.Invoke(model));
        }
    }
    

    And usage

    RuleFor(vm => vm.Nationality.SelectedItem.Value)
      .NotEmpty()
      .Length(0, 255)
      .ApplyChainValidation(vm => vm.Nationality.SelectedItem.Value);
    

    There is no possibility to escape redundant expression duplication, because When() method, which used inside extension method, works for previously defined rules only.

    Note: solution work for chains with reference types only.