FluentValidation 在验证字典项时使用数组索引而不是字典键(Items[0].Name 而不是 Items["foo"].Name)

问题描述 投票:0回答:1

序言:这是关于 .NET 4 和 ASP.NET 5 以及 FluentValidation 8.5 的 旧问题的后续。虽然这可以被认为是重复的,但多年来 FluentValidation 已经发生了足够的变化,我认为一个新问题是合理的。另外,这有一种更简单的方法来重现问题。另请参阅 GitHub 上 8 年前的问题,该问题声称对词典有“更好的支持”。


我正在尝试使用 FluentValidate 11.9.1 验证字典。虽然 .NET 中的字典实现了

IEnumerable<KeyValuePair<T1, T2>
,但它们应该使用索引表示法来访问,例如
items["foo"]
。下面是我发现重现此问题的最少代码,然后我将详细解释为什么在我的现实世界应用程序中验证字典是必要的。

首先,强制性框架信息:

  • .NET 框架 8
  • Visual Studio 2022 中的 MS 测试项目
  • FluentValidation 11.9.1

型号

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; }
}

非常简单,但足以说明

string
字典中的
Items
键是必要的,因为这最终用于整体 MVC 应用程序,其中 JavaScript 动态地将项目添加到此列表(稍后会详细介绍)。

验证者

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);
    }
}

失败的测试

这个测试说明了我的期望。

[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");
    }
}

单元测试失败并显示以下消息:

 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"

为什么我在模型中使用字典

Visual Studio 中的

Intellisense 显示

RuleForEach(model => model.Items)
需要
IEnumerable<KeyValuePair<string, OrderItem>>
。从技术上来说这是正确的。
Dictionary<T1, T2>
类实现
IEnumerable<T>
,但是 FluentValidation 为字典项生成的属性名称会破坏 Razor 模板视图中的 ASP.NET MVC 模型和验证消息绑定。在 Razor 模板中调用
@Html.EditorFor(model => model.Items["foo"].Name)
会生成
Items[foo].Name
形式的表单字段名称。
ValidationMessageFor
帮助程序预计 MVC ModelState 中会出现同名的验证错误。相反,FluentValidation 使用数字索引,就好像这是一个列表或数组而不是字典。

不幸的是,我在应用程序的视图层中被字典困住了。它是一个旧的 MVC 5 整体式 Web 应用程序。旧的 FluentValidation 验证器属性将字典模型验证消息正确地粘合在一起,因此目前在 .NET 4/MVC 5/FluentValidation 8.5 中工作(有关更多详细信息,请参阅我的旧问题)。我在客户端上运行 JavaScript,它在页面加载后动态添加项目。我需要用

Date.now()
替换 HTML 模板中的特殊标记,以在字典中生成新键,并确保完整的表单被完整回传。如果不完全重新连接许多相当大且复杂的表单,我就无法使用不同的数据结构。我需要使用词典。

我的问题

如何在 FluentValidation 中验证字典,使 PropertyName 为

Items["foo"].Name
而不是
Items[0].Name


一个好的答案可能是(按偏好顺序):

  1. (理想情况下)“这是 API 调用的神奇顺序,可以让它发挥作用!”
  2. 编写自定义验证器或重写 FluentValidation 的其他部分以获取所需的属性名称。
  3. 只要我们可以继续使用字典,就可以在 C# 中重构验证器。
  4. “这是不支持的,提交 PR 来实现你想要的。”
c# fluentvalidation
1个回答
0
投票

我找到了一个半优雅的解决方案,满足我的第二个偏好。通过一些扩展方法、一些类反射和带有验证失败消息的体操,我的测试通过了。

模型结构完全保持不变,但为了完整起见,我将在此处重新发布它:

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; }
}

所有更改都归入验证器类。

订单项目验证器

这个需要稍作修改才能继承

AbstractValidator<OrderItem>
:

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

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

订单表单验证器

覆盖

Validate(...)
方法似乎可以解决问题,前提是您使用本答案末尾的扩展方法:

public class OrderFormValidator : AbstractValidator<OrderForm>
{ //                     formerly AbstractValidator<KeyValuePair<string, OrderForm>>
    private readonly OrderItemValidator _itemValidator;

    public OrderFormValidator(OrderItemValidator itemValidator)
    {
        _itemValidator = itemValidator;

        RuleFor(model => model.Items)
            .NotEmpty();
    }

    public override ValidationResult Validate(ValidationContext<OrderForm> context)
    {
        var results = base.Validate(context);

        return context.Validate(model => model.Items, _itemValidator)
                      .AddToResults(results);
    }
}

字典项验证的扩展方法

这就是体操发挥作用的地方。

public static class FluentValidationDictionaryExtensions
{
    private static readonly Regex propertyNamePattern = new(@"^[^=]+=> *[^.]+\.(\w+)$");

    /// <summary>
    /// Validates a <see cref="Dictionary{TKey, TValue}"/> property using dictionary keys in the {PropertyName} of validation failures.
    /// </summary>
    /// <typeparam name="TModel"></typeparam>
    /// <typeparam name="TKey"></typeparam>
    /// <typeparam name="TValue"></typeparam>
    /// <param name="context">The current validation context.</param>
    /// <param name="propertyExpression">A lambda expression that returns the <see cref="Dictionary{TKey, TValue}"/> property to be validated.</param>
    /// <param name="validator">The validator used to validate each item in the dictionary identified by the <paramref name="propertyExpression"/>.</param>
    /// <returns>A collection of validation failures.</returns>
    public static IEnumerable<ValidationFailure> Validate<TModel, TKey, TValue>(this ValidationContext<TModel> context, Expression<Func<TModel, IDictionary<TKey, TValue>>> propertyExpression, IValidator<TValue> validator)
    {
        var propertyName = propertyNamePattern.Replace(propertyExpression.ToString(), "$1");
        var getDictionary = propertyExpression.Compile();
        var dictionary = getDictionary(context.InstanceToValidate);
        var results = new List<ValidationFailure>();

        if (dictionary != null)
        {
            foreach (var item in dictionary)
            {
                var itemResults = validator.Validate(item.Value);

                foreach (var error in itemResults.Errors)
                {
                    results.Add(new ValidationFailure(@$"{propertyName}[""{item.Key}""].{error.PropertyName}", error.ErrorMessage));
                }
            }
        }

        return results;
    }

    /// <summary>
    /// Validates a <see cref="Dictionary{TKey, TValue}"/> property using dictionary keys in the {PropertyName} of validation failures, and adds them to the given validation results.
    /// </summary>
    /// <typeparam name="TModel"></typeparam>
    /// <typeparam name="TKey"></typeparam>
    /// <typeparam name="TValue"></typeparam>
    /// <param name="context">The current validation context.</param>
    /// <param name="propertyExpression">A lambda expression that returns the <see cref="Dictionary{TKey, TValue}"/> property to be validated.</param>
    /// <param name="validator">The validator used to validate each item in the dictionary identified by the <paramref name="propertyExpression"/>.</param>
    /// <param name="results">The validation results object to add errors to.</param>
    /// <returns>The current validation context to enable method chaining.</returns>
    public static ValidationContext<TModel> Validate<TModel, TKey, TValue>(this ValidationContext<TModel> context, Expression<Func<TModel, IDictionary<TKey, TValue>>> propertyExpression, IValidator<TValue> validator, ValidationResult results)
    {
        context.Validate(propertyExpression, validator)
               .AddToResults(results);

        return context;
    }

    /// <summary>
    /// Adds the given collection of validation failures to the current validation result.
    /// </summary>
    /// <param name="failures">The failures to add.</param>
    /// <param name="results">The validation result set to add the failures to.</param>
    /// <returns>The same validation result object to enable method chaining.</returns>
    public static ValidationResult AddToResults(this IEnumerable<ValidationFailure> failures, ValidationResult results)
    {
        results.Errors.AddRange(failures);

        return results;
    }
}

一些额外的使用说明:

  • 您可以将多个字典属性链接在一起以增加便利。

    public override ValidationResult Validate(ValidationContext<OrderForm> context)
    {
        var results = base.Validate(context);
    
        return context.Validate(model => model.Foo, _fooValidator, results)
                      .Validate(model => model.Bar, _barValidator, results)
                      .Validate(model => model.Baz, _bazValidator)
                      .AddToResults(results);
    }
    
  • 这不是一个完美的解决方案,我不知道它是否支持嵌套在其他字典中的字典。或者嵌套在列表中的字典。

我希望在 FluentValidation 中看到正确的字典验证,我确信这不是一件小事。这可能适合我的目的。我会等待一段时间接受这个作为我的答案。我需要在我的应用程序中实际尝试并运行自动化测试以确保是否按预期工作。另外,这给了其他人一些时间来发布自己的答案。

如果有人能为我的第一偏好提供一个解决方案,或者为我的第二偏好提供一个不太脆弱的解决方案,我很乐意给予 1,000 点奖励。

© www.soinside.com 2019 - 2024. All rights reserved.