How to selectively validate some data annotation attribute?

5.3k views Asked by At

There are some properties in my view model that are optional when saving, but required when submitting. In a word, we allow partial saving, but the whole form is submitted, we do want to make sure all required fields have values.

The only approaches I can think of at this moment are:

Manipulate the ModelState errors collection.

The view model has all [Required] attributes in place. If the request is partial save, the ModelState.IsValid becomes false when entering the controller action. Then I run through all ModelState (which is an ICollection<KeyValuePair<string, ModelState>>) errors and remove all errors raised by [Required] properties.

But if the request is to submit the whole form, I will not interfere with the ModelState and the [Required] attributes take effect.

Use different view models for partial save and submit

This one is even more ugly. One view model will contain all the [Required] attributes, used by an action method for submitting. But for partial save, I post the form data to a different action which use a same view model without all the [Required] attributes.

Obviously, I would end up with a lot of duplicate code / view models.

The ideal solution

I have been thinking if I can create a custom data annotation attribute [SubmitRequired] for those required properties. And somehow make the validation ignores it when partial saving but not when submitting.

Still couldn't have a clear clue. Anyone can help? Thanks.

3

There are 3 answers

0
Blaise On BEST ANSWER

My approach is to add conditional checking annotation attribute, which is learned from foolproof.

Make SaveMode part of the view model.

Mark the properties nullable so that the values of which are optional when SaveMode is not Finalize.

But add a custom annotation attribute [FinalizeRequired]:

[FinalizeRequired]
public int? SomeProperty { get; set; }

[FinalizeRequiredCollection]
public List<Item> Items { get; set; }

Here is the code for the Attribute:

[AttributeUsage(AttributeTargets.Property)]
public abstract class FinalizeValidationAttribute : ValidationAttribute
{
    public const string DependentProperty = "SaveMode";

    protected abstract bool IsNotNull(object value);

    protected static SaveModeEnum GetSaveMode(ValidationContext validationContext)
    {
        var saveModeProperty = validationContext.ObjectType.GetProperty(DependentProperty);

        if (saveModeProperty == null) return SaveModeEnum.Save;

        return (SaveModeEnum) saveModeProperty.GetValue(validationContext.ObjectInstance);
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var saveMode = GetSaveMode(validationContext);

        if (saveMode != SaveModeEnum.SaveFinalize) return ValidationResult.Success;

        return (IsNotNull(value))
            ? ValidationResult.Success
            : new ValidationResult(string.Format("{0} is required when finalizing", validationContext.DisplayName));
    }
}

For primitive data types, check value!=null:

[AttributeUsage(AttributeTargets.Property)]
public class FinalizeRequiredAttribute : FinalizeValidationAttribute
{
    protected override bool IsNotNull(object value)
    {
        return value != null;
    }
}

For IEnumerable collections,

[AttributeUsage(AttributeTargets.Property)]
public  class FinalizeRequiredCollectionAttribute : FinalizeValidationAttribute
{
    protected override bool IsNotNull(object value)
    {
        var enumerable = value as IEnumerable;
        return (enumerable != null && enumerable.GetEnumerator().MoveNext());
    }
}

This approach best achieves the separation of concerns by removing validation logic out of controller. Data Annotation attributes should handle that kind of work, which controller just need a check of !ModelState.IsValid. This is especially useful in my application, because I would not be able to refactor into a base controller if ModelState check is different in each controller.

0
hutchonoid On

This is one approach I use in projects.

Create a ValidationService<T> containing the business logic that will check that your model is in a valid state to be submitted with a IsValidForSubmission method.

Add an IsSubmitting property to the view model which you check before calling the IsValidForSubmission method.

Only use the built in validation attributes for checking for invalid data i.e. field lengths etc.

Create some custom attributes within a different namespace that would validate in certain scenarios i.e. [RequiredIfSubmitting] and then use reflection within your service to iterate over the attributes on each property and call their IsValid method manually (skipping any that are not within your namespace).

This will populate and return a Dictionary<string, string> which can be used to populate ModelState back to the UI:

var validationErrors = _validationService.IsValidForSubmission(model);

if (validationErrors.Count > 0)
{
    foreach (var error in validationErrors)
    {
        ModelState.AddModelError(error.Key, error.Value);
    }
}
3
Priyank Sheth On

I think there is more precise solution for your problem. Lets say you're submitting to one method, I mean to say you are calling same method for Partial and Full submit. Then you should do like below:

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult YourMethod(ModelName model)
        {
          if(partialSave) // Check here whether it's a partial or full submit
          {
            ModelState.Remove("PropertyName");
            ModelState.Remove("PropertyName2");
            ModelState.Remove("PropertyName3");
          }

          if (ModelState.IsValid)
          {
          }
        }

This should solve your problem. Let me know if you face any trouble.

Edit:

As @SBirthare commented that its not feasible to add or remove properties when model get updated, I found below solution which should work for [Required] attribute.

 ModelState.Where(x => x.Value.Errors.Count > 0).Select(d => d.Key).ToList().ForEach(g => ModelState.Remove(g));

Above code will get all keys which would have error and remove them from model state. You need to place this line inside if condition to make sure it runs in partial form submit. I have also checked that error will come for [Required] attribute only (Somehow model binder giving high priority to this attribute even you place it after/below any other attribute). So you don't need to worry about model updates anymore.