How can I access the collection item being validated when using RuleForEach?
Asked Answered
L

2

35

I'm using FluentValidation to validate an object, and because this object has a collection member I'm trying to use RuleForEach. For example, suppose we have Customer and Orders, and we want to ensure that no customer order has a total value that exceeds the maximum allowed for that customer:

this.RuleForEach(customer => customer.Orders)
    .Must((customer, orders) => orders.Max(order => order.TotalValue) <= customer.MaxOrderValue)

So far, so good. However, I also need to record additional information about the context of the error (e.g. where in a data file the error was found). I've found this quite difficult to achieve with FluentValidation, and my best solution so far is to use the WithState method. For example, if I find that the customer's address details are incorrect, I might do something like this:

this.RuleFor(customer => customer.Address)
    .Must(...)
    .WithState(customer => GetErrorContext(customer.Address))

(where GetErrorContext is a method of mine to extract the relevant details.

Now the problem I have is that when using RuleForEach, the method signature assumes that I'll provide an expression that references the Customer, not the particular Order that caused the validation failure. And I seem to have no way to tell which order had a problem. Hence, I can't store the appropriate context information.

In other words, I can only do this:

this.RuleForEach(customer => customer.Orders)
    .Must((customer, orders) => orders.Max(order => order.TotalValue) <= customer.MaxOrderValue)
    .WithState(customer => ...)

...when I really wanted to do this:

this.RuleForEach(customer => customer.Orders)
    .Must((customer, orders) => orders.Max(order => order.TotalValue) <= customer.MaxOrderValue)
    .WithState(order => ...)

Is there really no way to access the details (or even the index) of the collection item(s) that failed?

I suppose another way of looking at it is that I want WithState to have an equivalent WithStateForEach...

Lackluster answered 30/11, 2014 at 12:8 Comment(0)
K
38

Currently there are no functionality in FluentValidation, that allows to set validation state the way you want. RuleForEach was designed to prevent creation of trivial validators for simple collection items, and it's implementation didn't cover all possible use cases.

You can create separate validator class for Order and apply it using SetCollectionValidator method. To access Customer.MaxOrderValue in Must method — add property to Order, that references backward to Customer:

public class CustomerValidator
{
    public CustomerValidator()
    {
        RuleFor(customer => customer.Orders).SetCollectionValidator(new OrderValidator());
    }
}

public class OrderValidator
{
    public OrderValidator()
    {
         RuleFor(order => order.TotalValue)
             .Must((order, total) => total <= order.Customer.MaxOrderValue)
             .WithState(order => GetErrorInfo(order)); // pass order info into state
    }
}

If you still want to use RuleForEach method, you can use error message instead of custom state, because it have access to both parent and child item entity objects in one of overloads:

public class CustomerValidator
{
    public CustomerValidator()
    {
        RuleForEach(customer => customer.Orders)
            .Must((customer, order) => order.TotalValue) <= customer.MaxOrderValue)
            .WithMessage("order with Id = {0} have error. It's total value exceeds {1}, that is maximum for {2}",
                (customer, order) => order.Id,
                (customer, order) => customer.MaxOrderValue,
                (customer, order) => customer.Name);
    }
}

If you need to collect all indexes (or identifiers) of failed orders — you can do it with Custom rule, like here:

public CustomerValidator()
{
    Custom((customer, validationContext) =>
    {
        var isValid = true;
        var failedOrders = new List<int>();

        for (var i = 0; i < customer.Orders.Count; i++)
        {
            if (customer.Orders[i].TotalValue > customer.MaxOrderValue)
            {
                isValid = false;
                failedOrders.Add(i);
            }
        }

        if (!isValid){
            var errorMessage = string.Format("Error: {0} orders TotalValue exceed maximum TotalValue allowed", string.Join(",", failedOrders));
            return new ValidationFailure("", errorMessage) // return indexes of orders through error message
            {
                CustomState = GetOrdersErrorInfo(failedOrders) // set state object for parent model here
            };
        }

        return null;
    });
}

P.S.

Do not forget that your goal is to implement validation, not to use FluentValidation everywhere. Sometimes we implement validation logic as a separate method, that works with ViewModel and fill ModelState in ASP.NET MVC.

If you can't find solution, that match your requirements, then manual implementation would be better than crutchful implementation with library.

Kamasutra answered 22/4, 2015 at 21:44 Comment(3)
Thanks. Both good ideas, although changing my model just to accommodate a limitation in FluentValidation doesn't sound good to me. The second idea isn't ideal for me, since I want an object (class) in my state, not just a string, but I'm sure it'd be sufficient for some. Thanks again.Lackluster
Is there any change to this, I have exactly the same problem, the rule is simple so I dont want to create a whole new validator for it and I want a class not just a message. Seems a bit poor to have the WithMessage taking both parent and child but not WithState.Lassitude
@Lassitude Added another solution, that could be helpful.Kamasutra
E
5

To complete @David Levin's answer, FluentValidations teams added an other way in the release 8.5 :

ChildRules

You can find it here.

With this, you will be able to use CustomState for each specific order.

public class CustomerValidator : AbstractValidator<Customer> 
{
      public CustomerValidator() 
      {
        RuleForEach(x => x.Orders).ChildRules(order => 
        {
          order.RuleFor(x => x.Total).GreaterThan(0)
                                     .WithMessage("The message you want to return")
                                     .WithState(x => new { Id = x.Id });
        });
      }
}
Epsilon answered 16/6, 2023 at 8:58 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.