Search code examples
c#asp.net-mvcasp.net-coreunobtrusive-validation

How to get parent object during AddValidationAttributes in ValidationHtmlAttributeProvider?


I'm trying to implement my custom validation attribute in ASP.NET Core MVC web app, including server and client side validation. The drawback of existing data annotations is static nature of attributes, which makes it impossible to pass runtime data to it.

I want to make GreaterThanAttribute, which takes a name of another property as parameter to compare the given value with the value of that property. Something like this:

public class TestModel 
{
    public int PropertyA { get; set; }

    [GreaterThan(nameof(PropertyA))]
    public int PropertyB { get; set; }
}

I've implemented the attribute in the following way:

[AttributeUsage(AttributeTargets.Property)]
public class GreaterThanAttribute : ValidationAttribute, IClientModelValidator
{
    public const string RuleName = "greaterthan";
    public const string ParameterName = "othervalue";
    public object OtherPropertyValue { get; set; }
    public string OtherPropertyName { get; }

    public GreaterThanAttribute(string otherPropertyName)
    {
        OtherPropertyName = otherPropertyName;
        ErrorMessage = $"The value should be greater than filed {OtherPropertyName}";
    }

    public void AddValidation(ClientModelValidationContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        MergeAttribute(context.Attributes, "data-val", "true");
        MergeAttribute(context.Attributes, $"data-val-{RuleName}", ErrorMessageString);
        MergeAttribute(context.Attributes, $"data-val-{RuleName}-{ParameterName}", OtherPropertyValue?.ToString());
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        // server side validation, no difficulties here
    }

    private static void MergeAttribute(IDictionary<string, string> attributes, string key, string value)
    {
        if (attributes.ContainsKey(key))
            return;
        attributes.Add(key, value);
    }
}

I've added unobtrusive validator for this attribute in my view:

<script type="text/javascript">
$.validator.addMethod("@GreaterThanAttribute.RuleName", function (value, element, params) {
    var parsedThisValue = Globalize.numberParser()(value);
    var parsedOtherValue = Globalize.numberParser()(params);
    return parsedThisValue > parsedOtherValue;
});
$.validator.unobtrusive.adapters.addSingleVal("@GreaterThanAttribute.RuleName", "@GreaterThanAttribute.ParameterName");
</script>

The problem is now I need to add data-val-greaterthan-othervalue attribute to the input field manually, like:

@{
    var propertyName = $"data-val-{GreaterThanAttribute.RuleName}-{GreaterThanAttribute.ParameterName}";
    var propertyValue = Model.Child[0].PropertyB;
}
@Html.TextBoxFor(x => Model.Child[0].PropertyA, new Dictionary<string, object> {{propertyName, propertyValue}})
@Html.ValidationMessageFor(x => Model.Child[0].PropertyA)

And I don't like it that way. So I'm looking a way to add this attribute using existing ASP.NET mechanisms without polluting views with it. The closest way to that I found in answer Access model data in AddValidation method asp.net core custom validation.

Now I'm trying to inject actual value into the attribute in my custom validation html attribute provider:

public class CustomValidationHtmlAttributeProvider : DefaultValidationHtmlAttributeProvider
{
    private readonly IOptions<MvcViewOptions> _optionsAccessor;
    private readonly IModelMetadataProvider _metadataProvider;
    private readonly ClientValidatorCache _clientValidatorCache;


    public CustomValidationHtmlAttributeProvider(IOptions<MvcViewOptions> optionsAccessor, IModelMetadataProvider metadataProvider, ClientValidatorCache clientValidatorCache) 
        : base(optionsAccessor, metadataProvider, clientValidatorCache)
    {
        _optionsAccessor = optionsAccessor;
        _metadataProvider = metadataProvider;
        _clientValidatorCache = clientValidatorCache;
    }

    public override void AddValidationAttributes(ViewContext viewContext, ModelExplorer modelExplorer, IDictionary<string, string> attributes)
    {
        // getting existing validation attribute
        var greaterThanAttribute = modelExplorer.Metadata.ValidatorMetadata.FirstOrDefault(x =>
            x.GetType() == typeof(GreaterThanAttribute)) as GreaterThanAttribute;
        var otherPropertyName = greaterThanAttribute.OtherPropertyName;

        // -------------
        // how to get reference to parent object of the model here?
        // -------------
        var otherValue = ?????????????

        greaterThanAttribute.OtherPropertyValue = otherValue;

        base.AddValidationAttributes(viewContext, modelExplorer, attributes);
    }
}

The problem is I can't find a way to get a reference to parent class of the property being validated in order to get value of PropertyB. All I have here is:

  • modelExplorer.Model is pointing to value of the PropertyA, as expected
  • modelExplorer.Container is pointing to value of whole Model of a view, which is complex, could have several levels of hierarchy and list. So basically I need get the value of Model.Child[0].PropertyA right now, but I don't know the exact path in advance. And of course I don't know index of current Child and so on.
  • modelExplorer.Metadata has all metadata for current property, for the whole container, but I don't see the way to connect this metadata to actual Model and values.

So the question is: how to reach the value of PropertyB here, considering the fact, that I don't know the whole hierarchy of the container? Maybe there is complete other way to achieve desired validation attribute?


Solution

  • I've finally found a solution to it.

    First of all, I've overloaded wrong version of the method. The correct one is void AddAndTrackValidationAttributes(ViewContext viewContext, ModelExplorer modelExplorer, string expression, IDictionary<string, string> attributes), because it provides expression string to current property with validation attributes.

    With this expression we can assume, that the target property with name stored in otherPropertyName is exist on the same path (because it is in the same class). For example, if expression is Model.Child[0].PropertyA, then target property could be retrieved with expression Model.Child[0].PropertyB.

    Unfortunately, ExpressionMetadataProvider.FromStringExpression function just don't work for expression with indexers. However, it works well with only properties expression. In my case, the only way is going through object hierarchy manually: parse the expression and go to properties and elements at given indexes using reflections.

    The whole code:

    public class CustomValidationHtmlAttributeProvider : DefaultValidationHtmlAttributeProvider
    {
        private readonly IOptions<MvcViewOptions> _optionsAccessor;
        private readonly IModelMetadataProvider _metadataProvider;
        private readonly ClientValidatorCache _clientValidatorCache;
    
    
        public CustomValidationHtmlAttributeProvider(IOptions<MvcViewOptions> optionsAccessor, IModelMetadataProvider metadataProvider, ClientValidatorCache clientValidatorCache) 
            : base(optionsAccessor, metadataProvider, clientValidatorCache)
        {
            _optionsAccessor = optionsAccessor;
            _metadataProvider = metadataProvider;
            _clientValidatorCache = clientValidatorCache;
        }
    
        public override void AddValidationAttributes(ViewContext viewContext, ModelExplorer modelExplorer, string expression, IDictionary<string, string> attributes)
        {
            // getting existing validation attribute
            var greaterThanAttribute = modelExplorer.Metadata.ValidatorMetadata.FirstOrDefault(x =>
                x.GetType() == typeof(GreaterThanAttribute)) as GreaterThanAttribute;
            var otherPropertyName = greaterThanAttribute.OtherPropertyName;
    
            var targetExpression = GetTargetPropertyExpression(expression, otherPropertyName);
            var otherValue = GetValueOnExpression(modelExplorer.Container.Model, targetExpression);
    
            greaterThanAttribute.OtherPropertyValue = otherValue;
    
            base.AddValidationAttributes(viewContext, modelExplorer, attributes);
        }
    
        private static object GetValueOnExpression(object container, string expression)
        {
            while (expression != "")
            {
                if (NextStatementIsIndexer(expression))
                {
                    var index = GetIndex(expression);
    
                    switch (container)
                    {
                        case IDictionary dictionary:
                            container = dictionary[index];
                            break;
                        case IEnumerable<object> enumerable:
                            container = enumerable.ElementAt(int.Parse(index));
                            break;
                        default:
                            throw new Exception($"{container} is unknown collection type");
                    }
    
                    expression = ClearIndexerStatement(expression);
                }
                else
                {
                    var propertyName = GetPropertyStatement(expression);
                    var propertyInfo = container.GetType().GetProperty(propertyName);
                    if (propertyInfo == null)
                        throw new Exception($"Can't find {propertyName} property in the container {container}");
    
                    container = propertyInfo.GetValue(container);
                    expression = ClearPropertyStatement(expression);
                }
            }
    
            return container;
        }
    
        private static bool NextStatementIsIndexer(string expression) 
            => expression[0] == '[';
    
        private static string ClearPropertyStatement(string expression)
        {
            var statementEndPosition = expression.IndexOfAny(new [] {'.', '['});
            if (statementEndPosition == -1) return "";
            if (expression[statementEndPosition] == '.') statementEndPosition++;
            return expression.Substring(statementEndPosition);
        }
    
        private static string GetPropertyStatement(string expression)
        {
            var statementEndPosition = expression.IndexOfAny(new [] {'.', '['});
            if (statementEndPosition == -1) return expression;
            return expression.Substring(0, statementEndPosition);
        }
    
        private static string ClearIndexerStatement(string expression)
        {
            var statementEndPosition = expression.IndexOf(']');
            if (statementEndPosition == expression.Length - 1) return "";
            if (expression[statementEndPosition + 1] == '.') statementEndPosition++;
            return expression.Substring(statementEndPosition + 1);
        }
    
        private static string GetIndex(string expression)
        {
            var closeBracketPosition = expression.IndexOf(']');
            return expression.Substring(1, closeBracketPosition - 1);
        }
    
        private static string GetTargetPropertyExpression(string sourceExpression, string targetProperty)
        {
            var memberAccessTokenPosition = sourceExpression.LastIndexOf('.');
            if (memberAccessTokenPosition == -1) // expression is just a property name
                return targetProperty;
            var newExpression = sourceExpression.Substring(0, memberAccessTokenPosition + 1) + targetProperty;
            return newExpression;
        }
    }