views:

357

answers:

2

(My apologies if this seems verbose - trying to provide all relevant code)

I've just upgraded to VS2010, and am now having trouble trying to get a new CustomModelBinder working.

In MVC1 I would have written something like

public class AwardModelBinder: DefaultModelBinder
{
    :
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        // do the base binding to bind all simple types
        Award award = base.BindModel(controllerContext, bindingContext) as Award;

        // Get complex values from ValueProvider dictionary
        award.EffectiveFrom = Convert.ToDateTime(bindingContext.ValueProvider["Model.EffectiveFrom"].AttemptedValue.ToString());
        string sEffectiveTo = bindingContext.ValueProvider["Model.EffectiveTo"].AttemptedValue.ToString();
        if (sEffectiveTo.Length > 0)
            award.EffectiveTo = Convert.ToDateTime(bindingContext.ValueProvider["Model.EffectiveTo"].AttemptedValue.ToString());
        // etc

        return award;
    }
}

Of course I'd register the custom binder in Global.asax.cs:

    protected void Application_Start()
    {
        RegisterRoutes(RouteTable.Routes);

        // register custom model binders
        ModelBinders.Binders.Add(typeof(Voucher), new VoucherModelBinder(DaoFactory.UserInstance("EH1303")));
        ModelBinders.Binders.Add(typeof(AwardCriterion), new AwardCriterionModelBinder(DaoFactory.UserInstance("EH1303"), new VOPSDaoFactory()));
        ModelBinders.Binders.Add(typeof(SelectedVoucher), new SelectedVoucherModelBinder(DaoFactory.UserInstance("IT0706B")));
        ModelBinders.Binders.Add(typeof(Award), new AwardModelBinder(DaoFactory.UserInstance("IT0706B")));
    }

Now, in MVC2, I'm finding that my call to base.BindModel returns an object where everything is null, and I simply don't want to have to iterate all the form fields surfaced by the new ValueProvider.GetValue() function.

Google finds no matches for this error, so I assume I'm doing something wrong.

Here's my actual code:

My domain object (infer what you like about the encapsulated child objects - I know I'll need custom binders for those too, but the three "simple" fields (ie. base types) Id, TradingName and BusinessIncorporated are also coming back null):

public class Customer
{
    /// <summary>
    /// Initializes a new instance of the Customer class.
    /// </summary>
    public Customer() 
    {
        Applicant = new Person();
        Contact = new Person();
        BusinessContact = new ContactDetails();
        BankAccount = new BankAccount();
    }

    /// <summary>
    /// Gets or sets the unique customer identifier.
    /// </summary>
    public int Id { get; set; }

    /// <summary>
    /// Gets or sets the applicant details.
    /// </summary>
    public Person Applicant { get; set; }

    /// <summary>
    /// Gets or sets the customer's secondary contact.
    /// </summary>
    public Person Contact { get; set; }

    /// <summary>
    /// Gets or sets the trading name of the business.
    /// </summary>
    [Required(ErrorMessage = "Please enter your Business or Trading Name")]
    [StringLength(50, ErrorMessage = "A maximum of 50 characters is permitted")]
    public string TradingName { get; set; }

    /// <summary>
    /// Gets or sets the date the customer's business began trading.
    /// </summary>
    [Required(ErrorMessage = "You must supply the date your business started trading")]
    [DateRange("01/01/1900", "01/01/2020", ErrorMessage = "This date must be between {0} and {1}")]
    public DateTime BusinessIncorporated { get; set; }

    /// <summary>
    /// Gets or sets the contact details for the customer's business.
    /// </summary>
    public ContactDetails BusinessContact { get; set; }

    /// <summary>
    /// Gets or sets the customer's bank account details.
    /// </summary>
    public BankAccount BankAccount { get; set; }
}

My controller method:

    /// <summary>
    /// Saves a Customer object from the submitted application form.
    /// </summary>
    /// <param name="customer">A populate instance of the Customer class.</param>
    /// <returns>A partial view indicating success or failure.</returns>
    /// <httpmethod>POST</httpmethod>
    /// <url>/Customer/RegisterCustomerAccount</url>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult RegisterCustomerAccount(Customer customer)
    {
        if (ModelState.IsValid)
        {
            // save the Customer

            // return indication of success, or otherwise
            return PartialView();
        }
        else
        {
            ViewData.Model = customer;

            // load necessary reference data into ViewData
            ViewData["PersonTitles"] = new SelectList(ReferenceDataCache.Get("PersonTitle"), "Id", "Name");

            return PartialView("CustomerAccountRegistration", customer);
        }
    }

My custom binder:

public class CustomerModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        ValueProviderResult vpResult = bindingContext
            .ValueProvider.GetValue(bindingContext.ModelName);
        // vpResult is null

        // MVC2 - ValueProvider is now an IValueProvider, not dictionary based anymore
        if (bindingContext.ValueProvider.GetValue("Model.Applicant.Title") != null)
        {
            // works
        }

        Customer customer = base.BindModel(controllerContext, bindingContext) as Customer;
        // customer instanitated with null (etc) throughout

        return customer;
    }
}

My binder registration:

    /// <summary>
    /// Application_Start is called once when the web application is first accessed.
    /// </summary>
    protected void Application_Start()
    {
        RegisterRoutes(RouteTable.Routes);

        // register custom model binders
        ModelBinders.Binders.Add(typeof(Customer), new CustomerModelBinder());

        ReferenceDataCache.Populate();
    }

... and a snippet from my view (could this be a prefix problem?)

    <div class="inputContainer">
        <label class="above" for="Model_Applicant_Title" accesskey="t"><span class="accesskey">T</span>itle<span class="mandatoryfield">*</span></label>
        <%= Html.DropDownList("Model.Applicant.Title", ViewData["PersonTitles"] as SelectList, "Select ...", 
            new { @class = "validate[required]" })%>
        <% Html.ValidationMessageFor(model => model.Applicant.Title); %>
    </div>
    <div class="inputContainer">
        <label class="above" for="Model_Applicant_Forename" accesskey="f"><span class="accesskey">F</span>orename / First name<span class="mandatoryfield">*</span></label>
        <%= Html.TextBox("Model.Applicant.Forename", Html.Encode(Model.Applicant.Forename),
                            new { @class = "validate[required,custom[onlyLetter],length[2,20]]", 
                                title="Enter your forename",
                                maxlength = 20, size = 20, autocomplete = "off",
                                  onkeypress = "return maskInput(event,re_mask_alpha);"
                            })%>
    </div>
    <div class="inputContainer">
        <label class="above" for="Model_Applicant_MiddleInitials" accesskey="i">Middle <span class="accesskey">I</span>nitial(s)</label>
        <%= Html.TextBox("Model.Applicant.MiddleInitials", Html.Encode(Model.Applicant.MiddleInitials),
                            new { @class = "validate[optional,custom[onlyLetter],length[0,8]]",
                                  title = "Please enter your middle initial(s)",
                                  maxlength = 8,
                                  size = 8,
                                  autocomplete = "off",
                                  onkeypress = "return maskInput(event,re_mask_alpha);"
                            })%>
    </div>
+1  A: 

Model binding changed significantly in MVC 2. It's full of "gotchas" -- even moreso than in MVC 1. E.g., an empty value in your form will make binding fail. None of this is well-documented. Realistically, the only good way to diagnose this stuff is to build with the MVC source code and trace through the binding.

I'm just glad the source code is available; I'd be lost without it.

Craig Stuntz
Thanks Craig - I'll attempt to build with the MVC source as you suggest - your MS Connect link seems to raise a question of the model prefix being inferred - is there a way to set that (now), say within the overridden BindModel, prior to attempting binding? Your answer does imply that I'm headed for per-field binding, if I have to check whether each field is blank before binding... gah!
Ian
Easiest way to set the prefix for a simple model is `[Bind]`. For a complex model, it can be the argument name. My connect report is about a *key* of empty string, not a *value*. This is (very) uncommon, but disastrous when it happens. There's nothing to check; binding just fails. The only fix is to get rid of the empty key in the form.
Craig Stuntz
Yes, sorry, I just noticed that in my watch of ValueProvider's FormValueProvider._prefixes list... there's one with no value (no key). I'll go hunt that down and report back. Many thanks.
Ian
Hi Craig. I can't seem to find anything in my view without an Id (at first I thought it was my AntiForgeryToken, but that is indeed there). The empty key is the first item in the ValueProvider's _prefixes dictionary, before the first field in my view). Stumped!
Ian
Solved. It looks as if the empty key in the _prefixes dictionary was (at least for me) a red herring.... see answer below.
Ian
+1  A: 

After downloading and building with the MVC2 RTM source (thanks to Craig for that link), I was able to step through the MVC code, and discovered that in the BindProperty method (on line 178 of DefaultModelBinder.cs), there is a test:

protected virtual void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor) {
    // need to skip properties that aren't part of the request, else we might hit a StackOverflowException
    string fullPropertyKey = CreateSubPropertyName(bindingContext.ModelName, propertyDescriptor.Name);
    if (!bindingContext.ValueProvider.ContainsPrefix(fullPropertyKey)) {
        return;
    }
    :

... that the ValueProvider dictionary contains keys with the prefix that is essentially the ModelName property of the custom model binder's bindingContext.

In my case, the bindingContext.ModelName had been inferred as "customer" (from my domain object type, I guess) and hence the test at line 181 always failed, therefore exiting BindProperty without binding my form value.

Here's my new custom model binder code:

public class CustomerModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        // vitally important that we set what is the prefix of values specified in view 
        // (usually "Model" if you've rendered a strongly-typed view after setting ViewData.Model)
        bindingContext.ModelName = "Model"; 
        Customer customer = base.BindModel(controllerContext, bindingContext) as Customer;

        return customer;
    }
}

I hope this helps anyone else who's having similar issues.

Many thanks to Craig for his help.

Ian