Hank
Hank

Reputation: 2646

Validate a collection with FluentValidation returning one failed-rule error for a property

I've just started using FluentValidation v9.x and am wondering how to go about validating rules in a collection.

Basically if I have a collection of substances

public class Substance
{
  public int? SubstanceId { get; set; }
  public string SubstanceName { get; set; }
  public decimal? SubstanceAmount { get; set; }
  public int? SubstanceUnitId { get; set; }
  public int? SubstanceRouteId { get; set; }
  public DateTimeOffset? SubstanceTime { get; set; }
}

and I want to validate that:

I'm looking to send back one error message for each of the rules if they fail, not for each substance, which is what is happening now with the following:

RuleForEach(x => x.SubstanceList).SetValidator(new SubstanceValidator(RuleSetsToApply));

public class SubstanceValidator : AbstractValidator<Substance>
{
  public SubstanceValidator(List<ValidationRule> RuleSetsToApply)
  {
    string ruleSetName = "SubstanceAmountUnit";
    RuleSet(ruleSetName, () => {
      RuleFor(x => x.SubstanceAmount).NotNull().NotEmpty();
      RuleFor(x => x.SubstanceUnitId).NotNull().NotEmpty().GreaterThan(0);
    });

    ruleSetName = "SubstanceIngestion";
    RuleSet(ruleSetName, () => {
      RuleFor(x => x.SubstanceTime).NotNull().NotEmpty();
    });

    ruleSetName = "SubstanceRoute";
    RuleSet(ruleSetName, () => {
      RuleFor(x => x.SubstanceRouteId).NotNull().NotEmpty().GreaterThan(0);
    });
  }
}

So if I have five substances and

How can I accomplish this?

Upvotes: 2

Views: 2716

Answers (2)

TRexF14
TRexF14

Reputation: 47

The Validate.Errors result is very easy to select/query from using LINQ. I try to keep my validation rules very cut/dry.

For instance, I have the following classes:

[Serializable]
[XmlRoot(ElementName ="Config")]
public class Config 
{
    [XmlElement("Machine")]
    public required Machine[] Machine { get; set; }

}
public class Machine
{
    [XmlElement]
    public required string Name { get; set; }

    [XmlElement]
    public required string IPAddress { get; set; }

    [XmlElement]
    public int Port { get; set; }

    [XmlElement]
    public required string Database { get; set; }

    public override string ToString()
    {
        return Name + " - " + IPAddress + " : " + Port;
    }
}

public class ConfigValidator : AbstractValidator<Config>
    {
    public ConfigValidator()
    {
        RuleFor(config => config.Machine)
            .NotNull()
            .NotEmpty()
            .Must(x => x.Length > 0);

        RuleForEach(x => x.Machine)
            .SetValidator(new MachineValidator());

    }
}
public class MachineValidator : AbstractValidator<Machine>
{
    public MachineValidator()
    {
        RuleFor(machine => machine.Name)
            .NotNull()
            .NotEmpty();

        RuleFor(machine => machine.IPAddress)
            .NotNull()
            .NotEmpty()
            .Matches("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$").WithMessage("IPAddress format must match ###.###.###.### (ex: 127.0.0.1).");

        RuleFor(machine => machine.Port)
            .NotNull()
            .NotEmpty()
            .LessThan(10000).WithMessage("Port numbers can only be 4 digits long and greater than 0.")
            .GreaterThan(0);

        RuleFor(machine => machine.Database)
            .NotNull()
            .NotEmpty();
    }
}

You can then use a small method (or single line of code) to get the distinct errors as a string. I use this to create a single string (with breaks) I can add to an alert popup message:

        public static string GetConfigErrors(Config config)
    {
        var configErrors = ConfigValidator.Validate(config).Errors.Count > 0 
                ? string.Join( Environment.NewLine, ConfigValidator.Validate(config).Errors.Select(x => x.ErrorMessage).Distinct() )
                : string.Empty;

        return configErrors;

    }

It removes any duplicates from the the error message list. For instance, if there are multiple bad IPAdresses or multiple bad Port values - I'll only see a single error message re: IPAdress and a single error message re: Port. The WithMessage extension is nice, because you can make it more informative than what the generic output would otherwise be. You can even use conditions to switch which Validator or which Rule is used to validate your property.

Upvotes: 0

rgvlee
rgvlee

Reputation: 3213

If I've understood the problem correctly, in this scenario I'd define the validation rules on the parent SubstanceList rather than a Substance entity validator.

For brevity I'm not considering your additional rule set logic as I don't know enough about it or whether it is actually required. However the following produces the three validation cases you have:

  • if SubstanceAmount and SubstanceUnitId has values, and
  • if SubstanceRouteId has a value, and
  • if SubstanceTime has a value on any of the substance items.
RuleFor(x => x.SubstanceList)
    .Must(x => x != null ? x.All(y => y.SubstanceAmount.HasValue && y.SubstanceUnitId.HasValue && y.SubstanceUnitId.Value <= 0) : true)
    .WithMessage(x => "One or more substance amounts or unit ids has not been provided, and/or one or more unit ids is less than or equal to 0.");

RuleFor(x => x.SubstanceList)
    .Must(x => x != null ? x.All(y => y.SubstanceTime.HasValue) : true)
    .WithMessage(x => "One or more substance times has not been provided.");

RuleFor(x => x.SubstanceList)
    .Must(x => x != null ? x.All(y => y.SubstanceRouteId.HasValue && y.SubstanceRouteId.HasValue && y.SubstanceRouteId.Value <= 0) : true)
    .WithMessage(x => "One or more substance route ids has not been provided or is less than or equal to 0.");

As per your example scenario:

So if I have five substances and

  • the first substance fails Rule #2,
  • the third fails Rule #1 and #2 and
  • the fourth fails on the Rule #3,

I would expect one error for each rule to be returned, even though Rule #2 has failed twice.

When I set up a sequence of Substances with the following conditions:

  • The first doesn't have a substance time (rule #2)
  • The third doesn't have a substance amount (rule #1) or time (rule #2), and
  • The fourth has a substance route id <= 0 (rule #3).

I get what I believe is the desired output:

enter image description here

Is there another way of doing it? The outcome is to basically distinct the error messages so yeah there are probably other approaches. One that springs to mind if you are invoking Validate manually is to post process the validation results and ensure the error messages are distinct. I prefer the above approach, it feels more certain and it gives me the opportunity to provide a suitable error message.

Working LINQPad example:

void Main()
{
    var fixture = new Fixture();
    var substances = new List<Substance>();
    substances.Add(fixture.Build<Substance>().Without(x => x.SubstanceTime).Create());
    substances.Add(fixture.Create<Substance>());
    substances.Add(fixture.Build<Substance>().Without(x => x.SubstanceAmount).Without(x => x.SubstanceTime).Create());
    substances.Add(fixture.Build<Substance>().With(x => x.SubstanceRouteId, -1).Create());
    substances.Add(fixture.Create<Substance>());
    Console.WriteLine(substances);

    var foo = new Foo() { SubstanceList = substances };
    var validator = new FooValidator();
    var validationResult = validator.Validate(foo);
    Console.WriteLine(validationResult.Errors.Select(x => x.ErrorMessage));
}

public class Substance
{
    public int? SubstanceId { get; set; }
    public string SubstanceName { get; set; }
    public decimal? SubstanceAmount { get; set; }
    public int? SubstanceUnitId { get; set; }
    public int? SubstanceRouteId { get; set; }
    public DateTimeOffset? SubstanceTime { get; set; }
}

public class Foo
{
    public List<Substance> SubstanceList { get; set; }
}

public class FooValidator : AbstractValidator<Foo>
{
    public FooValidator()
    {
        RuleFor(x => x.SubstanceList)
            .Must(x => x != null ? x.All(y => y.SubstanceAmount.HasValue && y.SubstanceUnitId.HasValue && y.SubstanceUnitId.Value <= 0) : true)
            .WithMessage(x => "One or more substance amounts or unit ids has not been provided, and/or one or more unit ids is less than or equal to 0.");

        RuleFor(x => x.SubstanceList)
            .Must(x => x != null ? x.All(y => y.SubstanceTime.HasValue) : true)
            .WithMessage(x => "One or more substance times has not been provided.");

        RuleFor(x => x.SubstanceList)
            .Must(x => x != null ? x.All(y => y.SubstanceRouteId.HasValue && y.SubstanceRouteId.HasValue && y.SubstanceRouteId.Value <= 0) : true)
            .WithMessage(x => "One or more substance route ids has not been provided or is less than or equal to 0.");
    }
}

Upvotes: 2

Related Questions