3
\$\begingroup\$

This is a follow up to:

Validating individual properties and whole items

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.

Changes:

  • Removed egregious casts from ValidateItem. Kept from refactoring to different methods because there's not a ton of code that's object specific.
  • Improved argument checking in ValidateProperty to also catch strings ending in '.' characters and be more concise.
  • Removed unused and unreachable code from GetRelativeProperty.

The code:

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

        var validationResults = new Collection<ValidationResult>();

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

        var itemAsEntity = item as Entity;

        if (itemAsEntity != null)
        {
            itemAsEntity.ValidationErrors.Clear();

            foreach (var validationResult in validationResults)
            {
                itemAsEntity.ValidationErrors.Add(validationResult);
            }
        }

        var itemAsComplexObject = item as ComplexObject;

        if (itemAsComplexObject != null)
        {
            itemAsComplexObject.ValidationErrors.Clear();

            foreach (var validationResult in validationResults)
            {
                itemAsComplexObject.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">Type of the target.</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 (string.IsNullOrWhiteSpace(propertyName) || propertyName.Last() == '.')
        {
            throw new ArgumentException("Argument must not be null, the empty string, whitespace or end in a '.' character.", "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);

        var targetAsEntity = target as Entity;
        if (targetAsEntity != null)
        {
            AddValidationErrorsToEntity(validationResults, validationContext, targetAsEntity);
        }

        var targetAsComplexObject = target as ComplexObject;
        if (targetAsComplexObject != null)
        {
            AddValidationErrorsToComplexObject(validationResults, validationContext, targetAsComplexObject);
        }

        return validationResults;
    }

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

    private static object GetRelativeProperty(string propertyName, object target)
    {
        if (propertyName.Contains('.'))
        {
            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.
                target = GetPropertyValue(target, propertyName.Substring(dotIndices[i] + 1, dotIndices[i + 1] - dotIndices[i] - 1));
            }
        }

        return target;
    }
}
\$\endgroup\$
  • 1
    \$\begingroup\$ +1 for using the word "egregious" about an older version of your own code. Oh who am I kidding? +1 just for using the word "egregious". \$\endgroup\$ – RubberDuck Jan 27 '15 at 11:41
1
\$\begingroup\$
    var itemAsEntity = item as Entity;

    if (itemAsEntity != null)
    {
        itemAsEntity.ValidationErrors.Clear();

        foreach (var validationResult in validationResults)
        {
            itemAsEntity.ValidationErrors.Add(validationResult);
        }
    }

    var itemAsComplexObject = item as ComplexObject;

    if (itemAsComplexObject != null)
    {
        itemAsComplexObject.ValidationErrors.Clear();

        foreach (var validationResult in validationResults)
        {
            itemAsComplexObject.ValidationErrors.Add(validationResult);
        }
    }

I don't have the classes Entity and ComplexObject, but if we strip the cast...

    if ((item as Entity) != null)
    {
        item.ValidationErrors.Clear();

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

    if ((item as ComplexObject) != null)
    {
        item.ValidationErrors.Clear();

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

Then the two if-statement bodies become the same.

Consider making a function that takes the Entity.ValidationErrors/ComplexObject.ValidationErrors property and clears it, then fills it with validationResults.

In the event that this is not possible, at least consider if/else-if:

    if ((item as Entity) != null)
    {
        item.ValidationErrors.Clear();

        foreach (var validationResult in validationResults)
        {
            item.ValidationErrors.Add(validationResult);
        }
    } 
    else if ((item as ComplexObject) != null)
    {
        item.ValidationErrors.Clear();

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

Because if something is Entity, surely it can't be ComplexObject, right?

\$\endgroup\$
  • \$\begingroup\$ Problem is, ValidationErrors is a property with only a getter, so I can neither pass it by reference (can't pass properties by reference) and I cannot set it either, unless I'm missing something. Then again, I guess I'm already using reflection... \$\endgroup\$ – Nick Udell Jan 27 '15 at 11:43
1
\$\begingroup\$

If ComplexObject and Entity could share an IValidationContainer interface which has an List<error> ValidationErrors property like

public interface IValidationContainer
{
    List<error> ValidationErrors { get; }
}  

you could remove one of the AddValidationErrorsToXXX() methods and adjust the other to take this interface as parameter.
This has also the advantage that the casts per method can be reduced to one cast.

Unfortunately Microsoft decided that they don't want to implement this interface, so we need to try something different.

Let us create a ValidationContainer class which has a ICollection<ValidationResult> property and takes either a ComplexObject, object or an Entity as constructorparameter. We also will add a Fill(ICollection<ValidationResult>) and a FillPreserve(ICollection<ValidationResult>,String) method

public class ValidationContainer
{
    public ICollection<ValidationResult> ValidationErrors
    {
        get;
        private set;
    }

    public void Fill(ICollection<ValidationResult> results)
    {
        ValidationErrors.Clear();
        foreach (ValidationResult result in results)
        {
            ValidationErrors.Add(result);
        }
    }

    public void FillPreserve(ICollection<ValidationResult> results, string contextMemberName)
    {
        var errors = new List<ValidationResult>();
        if (!String.IsNullOrEmpty(contextMemberName))
        {

            errors = ValidationErrors.Where(
                error => !error.MemberNames.Any(
                    memberName => memberName.EndsWith(contextMemberName, StringComparison.Ordinal))).ToList();
        }

        errors.AddRange(results);
        Fill(errors);
    }

    public ValidationContainer(Entity entity)
    {
        ValidationErrors = entity.ValidationErrors;
    }

    public ValidationContainer(ComplexObject complexObject)
    {
        ValidationErrors = complexObject.ValidationErrors;
    }

    public ValidationContainer(object obj)
    {
        ComplexObject complexObject = obj as ComplexObject;
        if (complexObject != null)
        {
            ValidationErrors = complexObject.ValidationErrors;
        }
        else
        {
            ValidationErrors = ((Entity)obj).ValidationErrors;
        }
    }
}

Now the end of the ValidateProperty() method would look like

    ComponentModelValidator.TryValidateProperty(value, validationContext, validationResults);

    ValidationContainer container = new ValidationContainer(target);
    container.FillPreserve(validationResults, validationContext.MemberName);

    return validationResults;
}

which makes it possible to remove the AddValidationErrorsToComplexObject() and the AddValidationErrorsToEntity() method.

The ValidateItem() method can be simplified to

public ICollection<ValidationResult> ValidateItem(object item)
{
    if (item == null)
    {
        throw new ArgumentNullException("item");
    }

    var validationResults = new Collection<ValidationResult>();

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

    ValidationContainer container = new ValidationContainer(item);
    container.Fill(validationResults);

    return validationResults;
}
\$\endgroup\$
  • 1
    \$\begingroup\$ It would be great if they could, but for some reason Microsoft decided they shouldn't. msdn.microsoft.com/en-us/library/… and msdn.microsoft.com/en-us/library/… \$\endgroup\$ – Nick Udell Jan 27 '15 at 12:49
  • \$\begingroup\$ Ah, now we know what we are talking about ;-) \$\endgroup\$ – Heslacher Jan 27 '15 at 12:52
  • \$\begingroup\$ The issue now is that the validation errors are will longer be added to the original Entity / Complex Object (which, thanks to Entity Framework rules, cannot be copied, cloned or shared). I guess having side effects in the method is kind of a bad idea anyway though, so maybe I should remove those. \$\endgroup\$ – Nick Udell Jan 27 '15 at 14:17

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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