Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Field annotated multiple times by the same attribute

For my ASP.NET MVC application I've created custom validation attribute, and indicated that more than one instance of it can be specified for a single field or property:

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = true)]
public sealed class SomeAttribute: ValidationAttribute

I've created validator for such an attribute:

public class SomeValidator : DataAnnotationsModelValidator<SomeAttribute>

and wire up this in the Application_Start of Global.asax

DataAnnotationsModelValidatorProvider.RegisterAdapter(
    typeof (SomeAttribute), typeof (SomeValidator));

Finally, if I use my attribute in the desired way:

[SomeAttribute(...)]  //first
[SomeAttribute(...)]  //second
public string SomeField { get; set; }

when validation is executed by the framework, only first attribute instance is invoked. Second one seems to be dead. I've noticed that during each request only single validator instance is created (for the first annotation).

How to solve this problem and fire all attributes?

like image 213
jwaliszko Avatar asked Aug 20 '14 20:08

jwaliszko


1 Answers

Let me answer myself. From MSDN (http://msdn.microsoft.com/en-us/library/system.attribute.typeid.aspx, http://msdn.microsoft.com/en-us/library/6w3a7b50.aspx):

When you define a custom attribute with AttributeUsageAttribute.AllowMultiple set to true, you must override the Attribute.TypeId property to make it unique. If all instances of your attribute are unique, override Attribute.TypeId to return the object identity of your attribute. If only some instances of your attribute are unique, return a value from Attribute.TypeId that would return equality in those cases. For example, some attributes have a constructor parameter that acts as a unique key. For these attributes, return the value of the constructor parameter from the Attribute.TypeId property.

As implemented, this identifier is merely the Type of the attribute. However, it is intended that the unique identifier be used to identify two attributes of the same type.

To summarize:

TypeId is documented as being a "unique identifier used to identify two attributes of the same type". By default, TypeId is just the type of the attribute, so when two attributes of the same type are encountered, they're considered "the same" by MVC validation framework.

The implementation:

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = true)]
public sealed class SomeAttribute: ValidationAttribute
{
    private string Parameter { get; set; }
    public override object TypeId
    {
        get { return string.Format("{0}[{1}]", GetType().FullName, Parameter); } 
    }

    public SomeAttribute(string parameter)
    {
        Parameter = parameter;
    }                

That way of TypeId creation is chosen over the alternatives below:

  • returning new object - it is too much, instances would be always different,
  • returning hash code based on string parameter - can lead to collisions (infinitely many strings can't be mapped injectively into any finite set - best unique identifier for string is the string itself, see here).

After it is done, server side validation cases work. Problem starts when this idea needs to be combined with unobtrusive client side validation. Suppose you have defined your custom validator in the following manner:

public class SomeAttributeValidator : DataAnnotationsModelValidator<SomeAttribute>
{
    public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
    {
        var rule = new ModelClientValidationRule {ValidationType = "someattribute"};
        rule.ValidationParameters.Add(...)
        yield return rule;
    }

Having this:

public class Model
{
    [SomeAttribute("first")]
    [SomeAttribute("second")]
    public string SomeField { get; set; }

results in following error:

Validation type names in unobtrusive client validation rules must be unique. The following validation type was seen more than once: someattribute

As said, the solution is to have unique validation types. We have to distinguish each registered attribute instance, which has been used to annotate a field, and provide corresponding validation type for it:

public class SomeAttributeValidator : DataAnnotationsModelValidator<SomeAttribute>
{
    private string AnnotatedField { get; set; }
    public SomeAttributeValidator(
        ModelMetadata metadata, ControllerContext context, SomeAttribute attribute)
        : base(metadata, context, attribute)
    {
        AnnotatedField = string.Format("{0}.{1}", 
            metadata.ContainerType.FullName, metadata.PropertyName);
    }

    public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
    {
        var count = Storage.Get<int>(AnnotatedField) + 1;
        Storage.Set(AnnotatedField, count);

        var suffix = char.ConvertFromUtf32(96 + count); // gets a lowercase letter 
        var rule = new ModelClientValidationRule
        {
            ValidationType = string.Format("someattribute{0}", suffix)
        };

(validation type has to consist only with lowercase letters - with the solution above, if you have more than 26 attributes - entire alphabet exhausted, expect an exception)

where Storage is simple utility which stores data for the current http request:

internal class Storage
{
    private static IDictionary Items
    {
        get
        {
            if (HttpContext.Current == null)
                throw new ApplicationException("HttpContext not available.");
            return HttpContext.Current.Items;
        }
    }

    public static T Get<T>(string key)
    {
        if (Items[key] == null)
            return default(T);
        return (T)Items[key];
    }

    public static void Set<T>(string key, T value)
    {
        Items[key] = value;
    }
}

Last, JavaScript part:

$.each('abcdefghijklmnopqrstuvwxyz'.split(''), function(idx, val) {
    var adapter = 'someattribute' + val;
    $.validator.unobtrusive.adapters.add(adapter, [...], function(options) {
        options.rules[adapter] = {
            ...
        };
        if (options.message) {
            options.messages[adapter] = options.message;
        }
    });
});

$.each('abcdefghijklmnopqrstuvwxyz'.split(''), function(idx, val) {
    var method = 'someattribute' + val;
    $.validator.addMethod(method, function(value, element, params) {
        ...
    }, '');
});

For complete solution, go through this sources.

like image 88
jwaliszko Avatar answered Nov 01 '22 13:11

jwaliszko