3
\$\begingroup\$

I have a custom class I've written which allows me to validate individual properties of items, without firing validation errors on other properties. This allows me to individually validate what my users are editing without flinging up a lot of red mess on fields they haven't gotten to yet.

The validator uses reflection to get the properties to validate, this is so that I can trigger property validation in my view model in response to control updates on the view, and this lets me use databinding strings to identify the properties without needing any access to the model on the view-side. The validation rules are specified using data annotation attributes on the model.

The validation methods attempt to attach validation errors to the object being validated if possible (i.e. if it's a ComplexObject or Entity type) but also returns a collection of them to be processed manually.

For completeness, the validator also allows me to validate whole items as a helper, for use when submitting final copies of items.

using ComponentModelValidator = System.ComponentModel.DataAnnotations.Validator;

/// <summary>
/// Uses data annotation attributes to validate whole items and individual properties
/// </summary>
public class Validator
{
    /// <summary>
    /// Validates properties on the provided item using validation attributes.
    /// </summary>
    /// <remarks>
    /// The validation errors are attached to the object if it is a ComplexObject or Entity.
    /// </remarks>
    /// <param name="item">The item to validate.</param>
    /// <returns>A collection of validation errors.</returns>
    /// <exception cref="System.ArgumentNullException">Thrown when item is null.</exception>
    public ICollection<ValidationResult> ValidateItem(object item)
    {
        if (item == null)
        {
            throw new ArgumentNullException("item");
        }
        if (item is Entity)
        {
            ((Entity)item).ValidationErrors.Clear();
        }
        if (item is ComplexObject)
        {
            ((ComplexObject)item).ValidationErrors.Clear();
        }

        var validationResults = new Collection<ValidationResult>();

        ComponentModelValidator.TryValidateObject(item, new ValidationContext(item), validationResults);

        if (item is Entity)
        {
            foreach (var validationResult in validationResults)
            {
                ((Entity)item).ValidationErrors.Add(validationResult);
            }
        }

        if (item is ComplexObject)
        {
            foreach (var validationResult in validationResults)
            {
                ((ComplexObject)item).ValidationErrors.Add(validationResult);
            }
        }

        return validationResults;
    }

    /// <summary>
    /// Validates the value of the property on the object provided using validation attributes.
    /// </summary>
    /// <remarks>
    /// The validation errors are attached to the object if it is a ComplexObject or Entity.
    /// </remarks>
    /// <typeparam name="T"></typeparam>
    /// <param name="item">The item that owns the property to be validated.</param>
    /// <param name="propertyName">Name of the property to be validated.</param>
    /// <returns>A collection of validation errors.</returns>
    /// <exception cref="System.ArgumentNullException"> Thrown when item or propertyName are null.</exception>
    /// <exception cref="System.ArgumentException">Thrown when propertyName is the empty String.</exception>
    public ICollection<ValidationResult> ValidateProperty<T>(T item, string propertyName)
    {
        if (item == null)
        {
            throw new ArgumentNullException("item");
        }
        if (propertyName == null)
        {
            throw new ArgumentNullException("propertyName");
        }
        if (propertyName.Equals(String.Empty))
        {
            throw new ArgumentException("Argument must not be the empty String.", "propertyName");
        }

        // need to keep this as object so we can access ValidationErrors regardless of whether target is an Entity or a ComplexObject, etc.
        object target = item;

        target = GetRelativeProperty(propertyName, target);

        var validationResults = new Collection<ValidationResult>();

        var validationContext = new ValidationContext(target, null, null)
        {
            MemberName = propertyName.Split('.').Last()
        };

        object value = GetPropertyValue(target, validationContext.MemberName);

        ComponentModelValidator.TryValidateProperty(value, validationContext, validationResults);

        if (target is Entity)
        {
            // remove existing validation errors for this property.
            var targetAsEntity = ((Entity)target);
            AddValidationErrorsToEntity(validationResults, validationContext, targetAsEntity);
        }
        else if (target is ComplexObject)
        {
            // remove existing validation errors for this property.
            var targetAsComplexObject = ((ComplexObject)target);
            AddValidationErrorsToComplexObject(validationResults, validationContext, targetAsComplexObject);
        }

        return validationResults;

    }

    private static object GetRelativeProperty(string propertyName, object target)
    {
        var prependPropertyName = String.Empty;

        if (propertyName.Contains('.'))
        {
            prependPropertyName = propertyName.Substring(0, propertyName.LastIndexOf('.') + 1);

            var dotIndices = new List<int>();

            // add -1 here, because we want to add the first block of text, but that doesn't begin with a '.', which we correct for later
            dotIndices.Add(-1);

            for (var i = 0; i < propertyName.Length; i++)
            {
                if (propertyName[i] == '.')
                {
                    dotIndices.Add(i);
                }
            }

            // we don't go through the last property because we want to grab the leaf object's parent, not the leaf object itself.
            for (var i = 0; i < dotIndices.Count - 1; i++)
            {
                // add 1 to the index to skip the full stop character.
                var prop = i == dotIndices.Count - 1 ?
                    propertyName.Substring(dotIndices[i] + 1) :
                    propertyName.Substring(dotIndices[i] + 1, dotIndices[i + 1] - dotIndices[i] - 1);

                target = GetPropertyValue(target, prop);
            }
        }

        return target;
    }

    private static void AddValidationErrorsToComplexObject(Collection<ValidationResult> validationResults, ValidationContext validationContext, ComplexObject targetAsComplexObject)
    {
        var errors = targetAsComplexObject.ValidationErrors.ToList();

        //Remove all errors that are in the new validation results list so we don't duplicate.
        errors.RemoveAll(
            error => error.MemberNames.Any(
                memberName => memberName.EndsWith(validationContext.MemberName, StringComparison.Ordinal)));

        targetAsComplexObject.ValidationErrors.Clear();

        foreach (var validationResult in validationResults.Concat(errors))
        {
            targetAsComplexObject.ValidationErrors.Add(validationResult);
        }
    }

    private static void AddValidationErrorsToEntity(Collection<ValidationResult> validationResults, ValidationContext validationContext, Entity targetAsEntity)
    {

        var errors = targetAsEntity.ValidationErrors.ToList();

        //Remove all errors that are in the new validation results list so we don't duplicate.
        errors.RemoveAll(
            error => error.MemberNames.Any(
                memberName => memberName.EndsWith(validationContext.MemberName, StringComparison.Ordinal)));

        targetAsEntity.ValidationErrors.Clear();

        foreach (var validationResult in validationResults.Concat(errors))
        {
            targetAsEntity.ValidationErrors.Add(validationResult);
        }
    }

    private static object GetPropertyValue(object target, string propertyName)
    {
        return target.GetType().GetProperty(propertyName).GetValue(target, null);
    }
}
\$\endgroup\$
1
  • 1
    \$\begingroup\$ GetRelativeProperty takes a base object and a property string like "Item.Value.Range.Maximum" and traverses the object's properties to get the Maximum property. \$\endgroup\$
    – Nick Udell
    Jan 22, 2015 at 17:24

1 Answer 1

2
\$\begingroup\$

ValidateItem

You are doing a lot of casts here. You should split this method into separate ones and call them like

public ICollection<ValidationResult> ValidateItem(object item)
{
    if (item == null)
    {
        throw new ArgumentNullException("item");
    }
    Entity entity = item as Entity;
    if (entity != null)
    {
        return ValidateEntity(entity);
    }
    return ValidateComplexObject(item as ComplexObject);
}

ValidateProperty

  • Instead of checking

    if (propertyName.Equals(String.Empty))

    you could check

    if (propertyName.Length == 0)  
    

    but hey, there are edge cases

    • what should happen if propertyName is containing only whitespace characters ? So better use the good old String.IsNullOrWhiteSpace().
    • what would happen if the last character is a dot . (propertyName.Split('.').Last())
  • And again you are first checking if target is Entity before you cast it. Better to use the way I showed above.

GetRelativeProperty

  • prependPropertyName isn't really used, remove it.

  • this

    var prop = i == dotIndices.Count - 1 ?  
                   propertyName.Substring(dotIndices[i] + 1) :  
                   propertyName.Substring(dotIndices[i] + 1, dotIndices[i + 1] - dotIndices[i] - 1);  
    

    The condition won't ever be true, because your loop has an ending condition of i < dotIndices.Count - 1.

AddValidationErrorsToComplexObject

  • wouldn't targetAsComplexObject.ValidationErrors.Clear(); be enough for clearing the errors ?

AddValidationErrorsToEntity

  • same question like for AddValidationErrorsToComplexObject
\$\endgroup\$
2
  • \$\begingroup\$ Great points! For your last two, I need to be able to re-add the errors I'm not changing (e.g. if there's an error on property "Name" but I'm validating "Id", I need "Name"'s validation errors to persist) which I do by copying the validation errors to 'error' and then only removing the errors for the property I am checking. When I then re-add the values in errors, the validation results for the properties I'm not checking are persisted. \$\endgroup\$
    – Nick Udell
    Jan 22, 2015 at 17:28
  • 1
    \$\begingroup\$ I missed validationResults.Concat(errors) ! \$\endgroup\$
    – Heslacher
    Jan 22, 2015 at 17:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.