Search code examples
c#fluentvalidation

FluentValidation uses array index instead of dictionary key when validating dictionary items (Items[0].Name instead of Items["foo"].Name)


Preamble: This is a follow-up to an older question about .NET 4 and ASP.NET 5 with FluentValidation 8.5. While this could be considered a duplicate, enough has changed with FluentValidation over the years that I thought a new question was justified. Plus, this has a simpler way of reproducing the problem. See also an 8-year-old issue on GitHub which claims "better support" for dictionaries.


I am attempting to validate a dictionary using FluentValidate 11.9.1. While dictionaries in .NET implement IEnumerable<KeyValuePair<T1, T2>, they are meant to be accessed using index notation, e.g. items["foo"]. Below is the minimal code I found to reproduce this problem, and then I'll explain more why validating dictionaries is necessary in my real world application after that.

First, the obligatory framework info:

  • .NET Framework 8
  • MS Test project in Visual Studio 2022
  • FluentValidation 11.9.1

Models

public class OrderForm
{
    public Dictionary<string, OrderItem> Items { get; set; } = new Dictionary<string, OrderItem>();
}

public class OrderItem
{
    public string? Name { get; set; }
    public decimal? Price { get; set; }
}

Pretty simple, but suffice to say the string key in the Items dictionary is necessary, because this is ultimately used in a monolith MVC application where JavaScript is dynamically adding items to this list (more on that later).

Validators

public class OrderFormValidator : AbstractValidator<OrderForm>
{
    public OrderFormValidator()
    {
        RuleFor(model => model.Items)
            .NotEmpty();

        RuleForEach(model => model.Items)
            .SetValidator(new OrderItemValidator());
    }
}

public class OrderItemValidator : AbstractValidator<KeyValuePair<string, OrderItem>>
{
    public OrderItemValidator()
    {
        RuleFor(model => model.Value.Name)
            .NotEmpty();

        RuleFor(model => model.Value.Price)
            .NotNull()
            .GreaterThan(0.0m);
    }
}

The Failing Test

This one test illustrates what I was expecting.

[TestClass]
public class OrderFormValidationTests
{
    [TestMethod]
    public void Given_empty_order_item_When_validating_Then_order_item_is_invalid()
    {
        var model = new OrderForm()
        {
            Items = new Dictionary<string, OrderItem>()
            {
                ["foo"] = new OrderItem()
            }
        };
        var validator = new OrderFormValidator();
        var results = validator.Validate(model);

        // the assertion below passes
        results.Errors.Count.ShouldBeEqualTo(2);

        // this assertion fails
        results.Errors[0].PropertyName.ShouldBeEqualTo(@"Items[foo].Price");
    }
}

The unit test fails with this message:

 Given_empty_order_item_When_validating_Then_order_item_is_invalid
   Source: UnitTest1.cs line 10
   Duration: 349 ms

  Message: 
Test method OrderFormValidationTests.Given_empty_order_item_When_validating_Then_order_item_is_invalid threw exception: 
FluentAssert.Exceptions.ShouldBeEqualAssertionException:   Expected string length 18 but was 19. Strings differ at index 6.
  Expected: "Items[foo].Price"
  But was:  "Items[0].Value.Name"

Why I'm Using Dictionaries in a Model

Intellisense in Visual Studio shows that RuleForEach(model => model.Items) expects an IEnumerable<KeyValuePair<string, OrderItem>>. Technically this is true. The Dictionary<T1, T2> class implements IEnumerable<T>, however the property names generated by FluentValidation for dictionary items break ASP.NET MVC model and validation message binding in Razor template views. Calls to @Html.EditorFor(model => model.Items["foo"].Name) in a Razor template generates form field names in the form of Items[foo].Name. The ValidationMessageFor helper expects a validation error in the MVC ModelState by the same name. Instead, FluentValidation is using numeric indices as if this were a List or array instead of a dictionary.

Unfortunately, I am stuck with dictionaries in the view layer of my application. It is an old MVC 5 monolith web application. The old FluentValidation validator attribute glued dictionary model validation messages together correctly, so this is currently working in .NET 4/MVC 5/FluentValidation 8.5 (see my older question for more details). I have JavaScript running on the client which adds items dynamically after page load. I need to replace a special token in the HTML template with Date.now() to generate a new key in the dictionary and ensure a complete form is POSTed back in full. Without completely replumbing many forms, which are quite large and complex, I cannot use a different data structure. I need to use dictionaries.

My Question

How can I validate dictionaries in FluentValidation such that the PropertyName is Items[foo].Name instead of Items[0].Name?


A good answer could be (in order of preference):

  1. (ideally) "Here is the magic sequence of API calls to get this to work!"
  2. Writing custom validators or overriding other parts of FluentValidation to get the desired property name.
  3. Restructuring validators in C# as long as we can keep using a dictionary.
  4. "This is not supported, submit a PR to implement what you want."

Solution

  • I didn't expect to find a solution mixing preferences #1 and #2.

    The OverrideIndexer() method allows you to change [0] to [foo] in the validation property names. This also required writing a KeyValuePair<T1, T2> validator. I topped it off with an extension method to make this a one-liner.

    First, just for the sake of completeness, the models:

    public class OrderForm
    {
        public Dictionary<string, OrderItem> Items { get; set; } = new Dictionary<string, OrderItem>();
    }
    
    public class OrderItem
    {
        public string? Name { get; set; }
        public decimal? Price { get; set; }
    }
    

    These remain unchanged from the code in the question above.

    Next, the validators:

    public class OrderFormValidator : AbstractValidator<OrderForm>
    {
        public OrderFormValidator(OrderItemValidator itemValidator)
        {
            RuleForEach(model => model.Items)
                .SetDictionaryItemValidator(itemValidator);
        } //    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    } //      (new extension method that does all the magic)
    
    public class OrderItemValidator : AbstractValidator<OrderItem>
    {
        public OrderItemValidator()
        {
            RuleFor(model => model.Name)
                .NotEmpty();
    
            RuleFor(model => model.Price)
                .NotNull()
                .GreaterThan(0.0m);
        }
    }
    

    Notice that OrderFormValidator uses a new extension method called SetDictionaryItemValidator. Source code for this new extension is below.

    public static class FluentValidationDictionaryExtensions
    {
        /// <summary>
        /// Sets the validator used to verify each item in this dictionary using the dictionary keys in validation message property names.
        /// </summary>
        /// <typeparam name="T">The <see cref="System.Type"/> which contains the dictionary being validated.</typeparam>
        /// <typeparam name="TKey">The <see cref="System.Type"/> of the dictionary keys.</typeparam>
        /// <typeparam name="TValue">The <see cref="System.Type"/> of the dictionary items.</typeparam>
        /// <param name="ruleBuilder">The rule applied to each item in this dictionary.</param>
        /// <param name="itemValidator">The validator used to verify each item in the dictionary.</param>
        /// <returns>The current rule builder to enable method chaining.</returns>
        public static IRuleBuilderOptions<T, KeyValuePair<TKey, TValue>> SetDictionaryItemValidator<T, TKey, TValue>(this IRuleBuilderInitialCollection<T, KeyValuePair<TKey, TValue>> ruleBuilder, IValidator<TValue> itemValidator)
        {
            return ruleBuilder.OverrideIndexer((model, items, currentItem, index) => "[" + currentItem.Key + "]")
                              .SetValidator(new KeyValuePairValidator<T, TKey, TValue>(itemValidator));
        }
    }
    

    And lastly, the IPropertyValidator for key-value pair objects which glues things together:

    public class KeyValuePairValidator<T, TKey, TValue> : IPropertyValidator<T, KeyValuePair<TKey, TValue>>
    {
        private readonly IValidator<TValue> itemValidator;
    
        public string Name { get; set; } = "Item";
    
        public KeyValuePairValidator(IValidator<TValue> itemValidator)
        {
            this.itemValidator = itemValidator;
        }
    
        public string GetDefaultMessageTemplate(string errorCode)
        {
            return "This {PropertyName} is invalid.";
        }
    
        public bool IsValid(ValidationContext<T> context, KeyValuePair<TKey, TValue> item)
        {
            var results = itemValidator.Validate(item.Value);
    
            foreach (var failure in results.Errors)
            {
                context.AddFailure(failure.PropertyName, failure.ErrorMessage);
            }
    
            return results.IsValid;
        }
    }
    

    It was difficult to find documentation for this. I had to pull down the FluentValidation code from GitHub and experiment. I found one thing worth noting. You must call context.AddFailure(string, string) to get the property names prefixed correctly for nested models. You'll see that in the KeyValuePairValidator class above. The significant tidbits are:

    1. Call RuleForEach(model => model.YourDictionaryProperty)
    2. Use the OverrideIndexer() method to change the format of the collection index so it uses the dictionary key instead of numeric index.
    3. Write a new property validator for the KeyValuePair<T1, T2> class.
      • This is necessary because dictionaries implement IEnumerable<KeyValuePair<TKey, TValue>, so FluentValidation sees them as collections like arrays or lists instead of dictionaries.
    4. Use the ValidationContext.AddFailure(string, string) overload to add child validation errors to the current validation error collection so property name prefixes are adjusted for nested models.