Como uso IValidatableObject?

182

Entendo que IValidatableObjecté usado para validar um objeto de uma maneira que permita comparar propriedades entre si.

Ainda gostaria de ter atributos para validar propriedades individuais, mas quero ignorar falhas em algumas propriedades em certos casos.

Estou tentando usá-lo incorretamente no caso abaixo? Se não, como faço para implementar isso?

public class ValidateMe : IValidatableObject
{
    [Required]
    public bool Enable { get; set; }

    [Range(1, 5)]
    public int Prop1 { get; set; }

    [Range(1, 5)]
    public int Prop2 { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!this.Enable)
        {
            /* Return valid result here.
             * I don't care if Prop1 and Prop2 are out of range
             * if the whole object is not "enabled"
             */
        }
        else
        {
            /* Check if Prop1 and Prop2 meet their range requirements here
             * and return accordingly.
             */ 
        }
    }
}
zrg
fonte

Respostas:

168

Primeiro, obrigado a @ paper1337 por me indicar os recursos certos ... Eu não estou registrado, então não posso votar nele, por favor, faça se alguém mais ler isso.

Veja como realizar o que eu estava tentando fazer.

Classe validável:

public class ValidateMe : IValidatableObject
{
    [Required]
    public bool Enable { get; set; }

    [Range(1, 5)]
    public int Prop1 { get; set; }

    [Range(1, 5)]
    public int Prop2 { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var results = new List<ValidationResult>();
        if (this.Enable)
        {
            Validator.TryValidateProperty(this.Prop1,
                new ValidationContext(this, null, null) { MemberName = "Prop1" },
                results);
            Validator.TryValidateProperty(this.Prop2,
                new ValidationContext(this, null, null) { MemberName = "Prop2" },
                results);

            // some other random test
            if (this.Prop1 > this.Prop2)
            {
                results.Add(new ValidationResult("Prop1 must be larger than Prop2"));
            }
        }
        return results;
    }
}

O uso Validator.TryValidateProperty()adicionará à coleção de resultados se houver falhas nas validações. Se não houver uma falha na validação, nada será adicionado à coleção de resultados, o que é uma indicação de sucesso.

Fazendo a validação:

    public void DoValidation()
    {
        var toValidate = new ValidateMe()
        {
            Enable = true,
            Prop1 = 1,
            Prop2 = 2
        };

        bool validateAllProperties = false;

        var results = new List<ValidationResult>();

        bool isValid = Validator.TryValidateObject(
            toValidate,
            new ValidationContext(toValidate, null, null),
            results,
            validateAllProperties);
    }

É importante definir validateAllPropertiescomo false para que esse método funcione. Quando validateAllPropertiesé falso, apenas as propriedades com um [Required]atributo são verificadas. Isso permite que o IValidatableObject.Validate()método manipule as validações condicionais.

zrg
fonte
Não consigo pensar em um cenário em que eu usaria isso. Você pode me dar um exemplo de onde você usaria isso?
Stefan Vasiljevic
Se você tiver colunas de rastreamento em sua tabela (como o usuário que a criou). É necessário no banco de dados, mas você entra no SaveChanges no contexto para preenchê-lo (eliminando a necessidade de os desenvolvedores se lembrarem de defini-lo explicitamente). Certamente, você validaria antes de salvar. Portanto, você não marca a coluna "criador" como necessário, mas valida com relação a todas as outras colunas / propriedades.
18716 MetalPhoenix
O problema com esta solução é que agora você depende do chamador para que seu objeto seja validado corretamente.
Cocogza
Para aprimorar essa resposta, pode-se usar a reflexão para encontrar todas as propriedades que possuem atributos de validação e chamar TryValidateProperty.
Paul Chernoch
78

Citação da postagem do blog de Jeff Handley sobre objetos e propriedades de validação com o validador :

Ao validar um objeto, o seguinte processo é aplicado no Validator.ValidateObject:

  1. Validar atributos no nível da propriedade
  2. Se algum validador for inválido, interrompa a validação retornando a (s) falha (s)
  3. Validar os atributos no nível do objeto
  4. Se algum validador for inválido, interrompa a validação retornando a (s) falha (s)
  5. Se na estrutura da área de trabalho e o objeto implementar IValidatableObject, chame seu método Validate e retorne qualquer falha

Isso indica que o que você está tentando fazer não funcionará imediatamente, pois a validação será interrompida na etapa 2. Você pode tentar criar atributos herdados dos incorporados e verificar especificamente a presença de uma propriedade ativada (por meio de uma interface) antes de executar sua validação normal. Como alternativa, você pode colocar toda a lógica para validar a entidade no Validatemétodo

Chris Shouts
fonte
36

Apenas para adicionar alguns pontos:

Como a Validate()assinatura do método retorna IEnumerable<>, isso yield returnpode ser usado para gerar preguiçosamente os resultados - isso é benéfico se algumas das verificações de validação exigirem muita IO ou CPU.

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
    if (this.Enable)
    {
        // ...
        if (this.Prop1 > this.Prop2)
        {
            yield return new ValidationResult("Prop1 must be larger than Prop2");
        }

Além disso, se você estiver usando MVC ModelState, poderá converter as falhas do resultado da validação em ModelStateentradas da seguinte maneira (isso pode ser útil se você estiver executando a validação em um fichário de modelo personalizado ):

var resultsGroupedByMembers = validationResults
    .SelectMany(vr => vr.MemberNames
                        .Select(mn => new { MemberName = mn ?? "", 
                                            Error = vr.ErrorMessage }))
    .GroupBy(x => x.MemberName);

foreach (var member in resultsGroupedByMembers)
{
    ModelState.AddModelError(
        member.Key,
        string.Join(". ", member.Select(m => m.Error)));
}
StuartLC
fonte
Agradável! Vale a pena usar atributos e reflexão no método Validate?
Schalk
4

Eu implementei uma classe abstrata de uso geral para validação

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace App.Abstractions
{
    [Serializable]
    abstract public class AEntity
    {
        public int Id { get; set; }

        public IEnumerable<ValidationResult> Validate()
        {
            var vResults = new List<ValidationResult>();

            var vc = new ValidationContext(
                instance: this,
                serviceProvider: null,
                items: null);

            var isValid = Validator.TryValidateObject(
                instance: vc.ObjectInstance,
                validationContext: vc,
                validationResults: vResults,
                validateAllProperties: true);

            /*
            if (true)
            {
                yield return new ValidationResult("Custom Validation","A Property Name string (optional)");
            }
            */

            if (!isValid)
            {
                foreach (var validationResult in vResults)
                {
                    yield return validationResult;
                }
            }

            yield break;
        }


    }
}
guneysus
fonte
1
Eu amo esse estilo de usar parâmetros nomeados, torna o código muito mais fácil de ler.
Drizin
0

O problema com a resposta aceita é que agora depende do chamador para que o objeto seja validado corretamente. Eu removeria o RangeAttribute e faria a validação do intervalo dentro do método Validate ou criaria uma subclasse de atributo personalizado RangeAttribute que leva o nome da propriedade necessária como argumento no construtor.

Por exemplo:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
class RangeIfTrueAttribute : RangeAttribute
{
    private readonly string _NameOfBoolProp;

    public RangeIfTrueAttribute(string nameOfBoolProp, int min, int max) : base(min, max)
    {
        _NameOfBoolProp = nameOfBoolProp;
    }

    public RangeIfTrueAttribute(string nameOfBoolProp, double min, double max) : base(min, max)
    {
        _NameOfBoolProp = nameOfBoolProp;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var property = validationContext.ObjectType.GetProperty(_NameOfBoolProp);
        if (property == null)
            return new ValidationResult($"{_NameOfBoolProp} not found");

        var boolVal = property.GetValue(validationContext.ObjectInstance, null);

        if (boolVal == null || boolVal.GetType() != typeof(bool))
            return new ValidationResult($"{_NameOfBoolProp} not boolean");

        if ((bool)boolVal)
        {
            return base.IsValid(value, validationContext);
        }
        return null;
    }
}
cocogza
fonte
0

Eu gostei da resposta de cocogza, exceto que a chamada base.IsValid resultou em uma exceção de estouro de pilha, uma vez que reintroduzia o método IsValid repetidamente. Então, eu o modifiquei para um tipo específico de validação, no meu caso, para um endereço de email.

[AttributeUsage(AttributeTargets.Property)]
class ValidEmailAddressIfTrueAttribute : ValidationAttribute
{
    private readonly string _nameOfBoolProp;

    public ValidEmailAddressIfTrueAttribute(string nameOfBoolProp)
    {
        _nameOfBoolProp = nameOfBoolProp;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        if (validationContext == null)
        {
            return null;
        }

        var property = validationContext.ObjectType.GetProperty(_nameOfBoolProp);
        if (property == null)
        {
            return new ValidationResult($"{_nameOfBoolProp} not found");
        }

        var boolVal = property.GetValue(validationContext.ObjectInstance, null);

        if (boolVal == null || boolVal.GetType() != typeof(bool))
        {
            return new ValidationResult($"{_nameOfBoolProp} not boolean");
        }

        if ((bool)boolVal)
        {
            var attribute = new EmailAddressAttribute {ErrorMessage = $"{value} is not a valid e-mail address."};
            return attribute.GetValidationResult(value, validationContext);
        }
        return null;
    }
}

Isso funciona muito melhor! Não falha e produz uma boa mensagem de erro. Espero que isso ajude alguém!

rjacobsen0
fonte
0

O que eu não gosto no iValidate é que ele parece ser executado APÓS todas as outras validações.
Além disso, pelo menos em nosso site, ele seria executado novamente durante uma tentativa de salvamento. Eu sugiro que você simplesmente crie uma função e coloque todo o seu código de validação nisso. Como alternativa para sites, você pode ter sua validação "especial" no controlador após a criação do modelo. Exemplo:

 public ActionResult Update([DataSourceRequest] DataSourceRequest request, [Bind(Exclude = "Terminal")] Driver driver)
    {

        if (db.Drivers.Where(m => m.IDNumber == driver.IDNumber && m.ID != driver.ID).Any())
        {
            ModelState.AddModelError("Update", string.Format("ID # '{0}' is already in use", driver.IDNumber));
        }
        if (db.Drivers.Where(d => d.CarrierID == driver.CarrierID
                                && d.FirstName.Equals(driver.FirstName, StringComparison.CurrentCultureIgnoreCase)
                                && d.LastName.Equals(driver.LastName, StringComparison.CurrentCultureIgnoreCase)
                                && (driver.ID == 0 || d.ID != driver.ID)).Any())
        {
            ModelState.AddModelError("Update", "Driver already exists for this carrier");
        }

        if (ModelState.IsValid)
        {
            try
            {
John Lord
fonte