How do I access other attribute values from inside a custom ValidationAttribute in an asp.net mvc 2 application?

半城伤御伤魂 提交于 2019-12-10 20:55:58

问题


I'm using asp.net mvc 2 with C# and DataAnnotations.

The situation is this: I have a custom control that is strongly typed against a model class. This control is displayed on the view several times but with differing property values, such as headings (Ex: Question 1, Question 2, Question 3, etc.. are all headings). I can write a custom validation class that validates this object as a whole, but the problem is, I can't get specific Html.ValidationMessage(...) tags to display. The validation errors only show in the summary at the top of the view and I want them to show at both the top and beside the specific control that failed a validation.

I've tried creating custom validation classes like the one below to validate on a property by property basis, but the issue is I need two of the model's values: the Rating and the Heading. The actual business validations are performed against the Rating, but the Heading property identifies to the user which control on the screen is incorrect.

    [AttributeUsage(AttributeTargets.Property)]
    public class RatingValidation : ValidationAttribute
     {

      public string Heading { get; private set; }  
      private readonly string ErrorRatingInvalid = "Rating for {0} is invalid. Rating is required and must be between 1 and 5.";

        public override string FormatErrorMessage(string name)
        {
            String errorMsg = ErrorRatingInvalid;
            return String.Format(CultureInfo.CurrentUICulture,
                                 errorMsg, 
                                 Heading);
        }

      public override bool IsValid(object value)
      {   
       bool isValidResult = true;

       if (value == null)
        return false;

        //Trying to do something like the following. This doesn't work because the 
        //attribute is applied to a property so only that property value is passed.
        //In this case, the type of myRatingObject would likely be the same as the 
        //property validated.
        var myRatingObject = TypeDescriptor.GetProperties(value);  
        this.Heading = myRatingObject.Heading;

        if( myRatingObject.Rating < 1 || myRatingObject.Rating > 5)
            isValidResult = false;

       return isValidResult;
      }
     }

Example of my model class:

    public class MyModel
    {
        public MyModel()
        {
            //this.IsEditable = true;
        }        
        public String Heading { get; set; }
        [RatingValidation()]
        public int Rating { get; set; }
    }

Thoughts?

EDIT 1

I wanted to put some more code down to better explain why creating separate class validators won't work as a solution in this case.

Recall that all of this validation is going into a custom partial view that is strongly typed. More accurately put, this is an Edit template. The main Model will have a List of the same type Model that this partial view displays. For example purposes, I have revised some of the above code to illustrate this better. I am leaving the code above this edit untouched so the discussion leading to this edit makes sense and can see where the conversation started and the direction it went.

Pardon the code as the demo app for testing this is rather verbose. Hopefully, it better explains my problem.

    [RatingsValidationAttribute()]
    public class PersonRating
    {
        public String Heading { get; set; }
        public int Index { get; set; }
        public int Rating { get; set; }
    }

    public class Person
    {
        public String FirstName { get; set; }
        public string LastName { get; set; }
        public int Age { get; set; }
        public List<PersonRating> Ratings { get; set; }
    }       

In my main View I have:

        <%= Html.EditorFor(m => m.Ratings) %>

The Ratings View control looks like:

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<ModelBindingResearch.Models.PersonRating>" %>
<%= Html.HiddenFor(m=>m.Heading) %>
<%= Html.HiddenFor(m=>m.Index) %>
 <%= Html.DisplayTextFor(model => model.Heading) %>
 :
 <%= Html.TextBoxFor(model => model.Rating) %>
 <%= Html.ValidationMessageFor(model => model.Rating, "*")%>
 <%= Html.ValidationMessageFor(m=>m) %>
 <br />         

The validation attribute class:

    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
        public class RatingsValidationAttribute : ValidationAttribute
        {
            public RatingsValidationAttribute()
            {

            }

            public int Rating { get; private set; }        
            public string Heading { get; set; }        

            private readonly string ErrorRatingInvalid = "Rating for {0} is invalid. Rating is required and must be between 1 and 5.";

            public override string FormatErrorMessage(string name)
            {
                String errorMsg = ErrorRatingInvalid;
                return String.Format(CultureInfo.CurrentUICulture,
                                     errorMsg,
                                     Heading);
            }

            public override bool IsValid(object value)
            {

                bool isValidResult = true;
                PersonRating personRating = (value as PersonRating);

                try
                {                
                    PropertyDescriptorCollection properties = TypeDescriptor.GetProperties(value);
                    Heading = personRating.Heading;

                    if (personRating.Rating < 1 ||      //Rating must be b/t 1 & 5
                        personRating.Rating > 5)
                    {
                        isValidResult = false;
                    }
                }
                catch (Exception e)
                {
                    //log error
                }

                return isValidResult;
            }
        }    

My test model binder. This doesn't do anything yet. It's just exploratory. Note that variable o contains the full object model and all values. This code borrows heavily from: ASP.NET MVC - Custom validation message for value types

    public class TestModelBinder : DefaultModelBinder
    {
        public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            Object o = base.BindModel(controllerContext, bindingContext);
            Object obj = bindingContext.Model;
            Person p = (Person)o;
            bindingContext.ModelState.AddModelError("FirstName", "Custom exception thrown during binding for firstname.");
            return o;
        }

        protected override void SetProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor, object value)
        {
            base.SetProperty(controllerContext, bindingContext, propertyDescriptor, value);
            String propertyName = propertyDescriptor.Name;
            Object propertyValue = value; 
        }

        private bool IsFormatException(Exception e)
        {
            if (e == null)
                return false;
            else if (e is FormatException)
                return true;
            else
                return IsFormatException(e.InnerException);
        }
    }           

My Home Controller (just the methods I added for the Create view):

    public ActionResult Create()
    {
        return View(getPerson());
    }

    [HttpPost]
    public ActionResult Create(Person p)
    {
        return View(p);
    }

    private Person getPerson()
    {
        Person p = new Person();
        Address a = new Address();
        PersonRating pr1 = new PersonRating();
        PersonRating pr2 = new PersonRating();
        PersonRating pr3 = new PersonRating();
        pr1.Heading = "Initiative";
        pr1.Rating = 5;
        pr1.Index = 1;
        pr2.Heading = "Punctuality";
        pr2.Rating = 5;
        pr1.Index = 2;
        pr3.Heading = "Technical Knowledge";
        pr3.Rating = 5;
        pr3.Index = 3;

        a.Street = "555 Somewhere Dr";
        a.City = "City";
        a.State = "AL";
        p.FirstName = "Jason";
        p.LastName = "Rhevax";
        p.Age = 30;
        p.PersonAddress = a;
        p.Ratings.Add(pr1);
        p.Ratings.Add(pr2);
        p.Ratings.Add(pr3);
        return p;
    }

Lastly, the Create.aspx in full:

    <%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<ModelBindingResearch.Models.Person>" %>

    <asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
        Create
    </asp:Content>

    <asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

        <h2>Create</h2>

        <% using (Html.BeginForm()) {%>
            <%= Html.ValidationSummary() %>

            <fieldset>
                <legend>Fields</legend>

                <div class="editor-label">
                    <%= Html.LabelFor(model => model.FirstName) %>
                </div>
                <div class="editor-field">
                    <%= Html.TextBoxFor(model => model.FirstName) %>
                    <%= Html.ValidationMessageFor(model => model.FirstName, "*") %>
                </div>

                <div class="editor-label">
                    <%= Html.LabelFor(model => model.LastName) %>
                </div>
                <div class="editor-field">
                    <%= Html.TextBoxFor(model => model.LastName) %>
                    <%= Html.ValidationMessageFor(model => model.LastName) %>
                </div>

                <div class="editor-label">
                    <%= Html.LabelFor(model => model.Age) %>
                </div>
                <div class="editor-field">
                    <%= Html.TextBoxFor(model => model.Age) %>
                    <%= Html.ValidationMessageFor(model => model.Age) %>
                </div>

                <div class="editor-label">
                    <%= Html.LabelFor(model => model.PersonAddress.Street) %>
                </div>
                <div class="editor-field">
                    <%= Html.TextBoxFor(model => model.PersonAddress.Street)%>
                    <%= Html.ValidationMessageFor(model => model.PersonAddress.Street)%>
                </div>

                <div class="editor-label">
                    <%= Html.LabelFor(model => model.PersonAddress.City) %>
                </div>
                <div class="editor-field">
                    <%= Html.TextBoxFor(model => model.PersonAddress.City)%>
                    <%= Html.ValidationMessageFor(model => model.PersonAddress.City)%>
                </div>

                <div class="editor-label">
                    <%= Html.LabelFor(model => model.PersonAddress.State) %>
                </div>
                <div class="editor-field">
                    <%= Html.TextBoxFor(model => model.PersonAddress.State)%>
                    <%= Html.ValidationMessageFor(model => model.PersonAddress.State)%>
                </div>     
                <div>
                    <%= Html.EditorFor(m => m.Ratings) %>
                </div>                               
                <p>
                    <input type="submit" value="Create" />
                </p>
            </fieldset>

        <% } %>

        <div>
            <%= Html.ActionLink("Back to List", "Index") %>
        </div>

    </asp:Content>      

Lastly, I registered my model binder in global.aspx's application_start method via:

ModelBinders.Binders.Add(typeof(Person), new TestModelBinder());

So, the validation below works, but here's the issue. The validation message displayed by the RatingsValidationAttribute class is correct, but I only want that message displayed at the top of the page. On the control, I just want an "*" displayed by the Ratings text box. You'll note that the following

<%= Html.ValidationMessageFor(model => model.Rating, "*")%>

does not display the asterisk. I suppose this is because the attribute validates at the class level. When inspecting the ModelState in my model binder, the key that actually holds the error message is something akin to: "Ratings[0]", which means to display that message, I have to use the following:

 <%= Html.ValidationMessageFor(model => model)%>

For one, this is ugly. Secondly, there will be more than one validation checked per partial control, so I want the error in the summary to detail which heading has an error and just use "*" asterisks to denote the controls that have an error.

I apologize for the length of the edit. I try to handle questions I ask on stack overflow in such a way that there is little doubt what the resolution was. Feel free to ask for more code (in case I forgot to post something) or any other questions.

EDIT 3

A general note about creating a validation attribute on the class level: when an validation fails and an error is added to the ModelState, the field name is the name of the class. Since my class being validated is a List of 3 objects that are contained in a List on the view's Model, the ModelState keys are something like: Model.ListName[index].

Is there a way to specify the key associated with the error message for a custom validation class that evaluates at the class level? For field level custom validation attributes, this isn't an issue.

EDIT 4

This blog post, http://blog.ceredir.com/index.php/2010/08/10/mvc2-cross-field-validation/, addresses the issues of this question. My only issue is it requires me to create 4 classes per validation I want to perform, which is pretty excessive. Still, it's the first working example that shows a way to have an validation attribute applied to a field/property and have access to the entire model for that class.

Now my only remaining issue is trying to intercept some behind the scenes validations the framework does for you. I'm talking about if you have an int field on your form and don't put a value, the framework adds a validation message like:

The _____ field is required. (where ______ is the name of the int field).

It also does something similar if you provide a text value for an int field:

The value 'asdf' is not valid for ______.

Because of my situation where these validations are being performed on a model that is iterated over several times, I need to amend these messages to display the Heading property of the object in the messages above. The above messages would change like so:

The __________ field is required. --> The {0} field for {1} is required.

The value 'asdf' is not valid for (field name). --> The {0} value of {1} is not valid for {2}. --> The Rating value of 'asdf' is not valid for Technical Knowledge.

I hope that made sense. I think I've fumbled this entire question into non-sense. I might come back and try to rephrase somehow. It'd help if I could get some kind of feedback.


回答1:


Wait for MVC 3. I'm being serious.

The options for class wide custom validation attributes are pretty poor right now without any really good extensibility points. Your pretty much stuck creating a custom ModelBinder, which can then add values to ModelState, to do anything complex with validation attributes.

Use the attributes just like you would and then detect what Types are requested from the binder, Reflect to find the attributes, and then validated/add to model state as necessary.

MVC 3 fixes this problem but until then your stuck creating your own binder.




回答2:


You could use a class attribute to achieve this:

[AttributeUsage(AttributeTargets.Class)]
public class RatingValidation : ValidationAttribute
{
    public override bool IsValid(object value)
    {
        var model = (MyModel)value;
        // TODO: here you have the model so work with the 
        // Rating and Heading properties to perform your 
        // validation logic

        return true;
    }
}

And you model:

[RatingValidation]
public class MyModel
{
    public String Heading { get; set; }
    public int Rating { get; set; }
}

Or even better: use FluentValidation which integrates nicely with ASP.NET MVC.




回答3:


Better phrasing of this question altogether:

How can I get a distinct error message to show for the validation summary for a given form field with a separate & distinct message showing beside the field for which the validation applies?

Example:

Field: Rating
Summary Message: "Rating for Technical Knowledge is required and must be between 1 and 5." Field Validation Message: "*"

Hopefully that illustrates what I'm trying to do. From the research the answer seems to be this isn't possible with the current data annotations framework without rolling at least one html helper on my own: either create my own ValidationSummary or my own ValidationMessage helper...




回答4:


I wanted to elaborate on jfar's suggestion. I am not agreeing that I need to wait till MVC 3; however, his point for creating a custom model binder is actually THE ONLY way to do what I am trying to do. I've tried both field/property level attributes and class level attributes. Neither of these are sufficient for the sort of messages I need.

For example, recall that my situation is that I have a template control that is strongly typed against a model with properties Index, Heading, & Rating. So a page of these controls displayed would be something like:

Heading: Technical Knowledge Rating: [text box]

Heading: Leadership Rating: [text box]

Heading: Work Ethic Rating: [text box]

...etc, etc.

For my validation messages, I wanted something very customized. It turns out my needs were too specific for the out of the box validating in MVC 2. I needed error messages for the Rating field to reference the Heading that Rating is associated with. So the validation would be something like:

The Rating for Work Ethic is required and must be a number between 1 and 5. The Rating value of 'asdf' for Work Ethic is not valid.

The issue with field level attributes is that they didn't have access to the Heading value. Furthermore, each object actually contains an IsEditable field and should that field's value be false, I bypass validations altogether.

The issue with class level attributes is two fold. For one, I can't control the key used in the ModelStateCollection. The default is the class name and index of the object on the page. This yields a result akin to: PersonRating[0], PersonRating[1]. The problem with this is that it means you can only have 1 error message at a class level validation. If you have 2 class level attributes, they both get put into the ModelStateCollection with the same key. I'm not really sure how that works as I wouldn't think a dictionary would let you do that. Perhaps it fails silently or the second message just overwrites the first. In addition to this, I still need the fields themselves to have their css changes to denote an error. With class level validations, I could not figure out how to do this because the key does not reference a field...so I have no idea which message goes with what field unless I do hard string checks, which seems a really bad solution.

The blog post I referenced earlier does provide a solution, but it requires far too much code and effort per attribute and just seemed like overkill.

In addition to all of this, MVC data annotations has basic validations hard wired into it to sort of save you from yourself. This includes invoking a Required validator if a type on your model is non-nullable. The internal MVC validations also do data checking to ensure you're not trying to submit a string value to a int type. In both of these cases, I didn't not see a viable way to correct the validation message without doing hard checks against the validation text.

So, in the end, I wrote my own model binder for this object. While this is not the recommended way, I take comfort in that this really is the only viable solution in my case due to the internal validations hardwired in the MVC framework. My model binder looked something like below:

        public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            Object o = base.BindModel(controllerContext, bindingContext);
            string ratingKey = bindingContext.ModelName + ".Rating";            
            PersonRating pr = (PersonRating)o;
            ValueProviderResult ratingVpr = controllerContext.
                                        Controller.
                                            ValueProvider.
                                                GetValue(ratingKey);
            String ratingVal = ratingVpr.AttemptedValue;
            String ratingErrorMessage = getRatingModelErrorMessage(
                                            ratingKey,
                                            ratingVal,
                                            pr);

            if (!String.IsNullOrEmpty(ratingErrorMessage))
            {
                bindingContext.ModelState[ratingKey].Errors.Clear();
                bindingContext.ModelState.AddModelError(ratingKey, ratingErrorMessage);
            }

            return o;

                         }

The getRatingModelErrorMessage method is a custom method that performs validations on the Rating field of the PersonRating object and returns a string that represents the error message. If the string is null, no error was returned by the getRatingModelErrorMessage method.

I'll be the first to admit this isn't great code. There's always room to improve. Still it gets the job done. It's also worth noting that in situations where a value such as a text value is submitted for a non compatible type on the model, such as int, it will not be bound to the model. I am getting that value from the FormCollection via it's key.

Let me know if anyone has any suggestions on other ways this could be done or comments on the code in general. I'm always open for constructive criticism.



来源:https://stackoverflow.com/questions/3720823/how-do-i-access-other-attribute-values-from-inside-a-custom-validationattribute

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!