Search code examples
c#asp.net-mvcrazorasp.net-mvc-4model-binders

how can I bind a complex class to a view, while preserving my custom validation attributes?


In my project I have a model class that uses another class, like in the sample below. One of the properties in the model depends for validation on on of the properties of a child object -- in this sample, LastName property depends for validation on the value of the Address.PostalCode property. I implemented a custom validation attribute to validate my LastName property and it works great.

public class User
{
    public static ValidationResult ValidateLastName(string lastName, ValidationContext context)
    {
        // Grab the model instance
        var user = context.ObjectInstance as User;
        if (user == null)
            throw new NullReferenceException();

        // Cross-property validation
        if (user.Address.postalCode.Length < 10000)
            return new ValidationResult("my LastName custom validation message.");

        return ValidationResult.Success;
    }

    [Display(Name = "Last name")]
    [CustomValidationAttribute(typeof(User), "ValidateLastName")]
    public string LastName { get; set; }

    [Display(Name = "First name")]
    public string FirstName { get; set; }

    [Display(Name = "Address:")]
    [CustomValidationAttribute(typeof(User), "ValidateAddress")]
    public AddressType Address { get; set; }
}

public class AddressType
{
    public string streetName = "";

    public string streetNumber = "";
    public string postalCode = "";
}

The problem is in the controller the Address property does not get constructed from the view, and it is always null. In this sample, user.Address is always null, regardless of what I send in the view. Here is the controller code.

    [HttpPost]
    public ActionResult Create(User user)
    {
        if (ModelState.IsValid)
        {
            // creation code here
            return RedirectToAction("Index");
        }
        else
        {
            return View(user);
        }
    }

Here is the view:

        <div class="editor-label">
            @Html.LabelFor(model => model.Address.postalCode)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Address.postalCode)
            @Html.ValidationMessageFor(model => model.Address.postalCode)
        </div>

To resolve this, I created a custom dummy binder to map the fields in the view to the properties in the model like so:

public class UserBinder : IModelBinder
{
    private string GetValue(ModelBindingContext bindingContext, string key)
    {
        var result = bindingContext.ValueProvider.GetValue(key);
        return (result == null) ? null : result.AttemptedValue;
    }

    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        User instance = new User();
        instance.FirstName = GetValue(bindingContext, "FirstName"); //controllerContext.HttpContext.Request["FirstName"];
        instance.LastName = GetValue(bindingContext, "LastName"); //controllerContext.HttpContext.Request["LastName"];

        instance.Address = new AddressType();
        string streetName = controllerContext.HttpContext.Request["Address.streetName"];

        //ModelStateDictionary mState = bindingContext.ModelState;
        //mState.Add("LastName", new ModelState { });
        //mState.AddModelError("LastName", "There's an error.");

        instance.Address.streetName = streetName;
                    ...
        return instance;
    }

The binder works fine, but the validation attributes do not work anymore. I think there must be a better way to do the binding than this, is there?

This binder is just mapping LastName to LastName and Address.streetName to Address.streetName, I imagine there should be a way to accomplish this without having to write all this tedious code and without breaking the custom validation mechanism.


Solution

  • One solution is to use properties instead of public fields in my child class -- thanks to Oded for the answer!

    Another solution is to call TryValidateModel in the controller, this enables my validation code even with the binder present.

        [HttpPost]
        public ActionResult Create(User user)
        {
            if (TryValidateModel(user))
            {
                // creation code here
                return RedirectToAction("Index");
            }
            else
            {
                return View(user);
            }
        }