ASP.NET MVC 3 Validation on Nested Objects not working as expected - validates child object twice and not parent object
Asked Answered
Q

3

8

I am trying to get ASP.NET MVC 3 to generate forms from complex, nested objects. There is one validation behaviour I found which was unexpected and I am not sure if it's a bug in the DefaultModelBinder or not.

If I have two objects, lets call the "parent" one "OuterObject", and it has a property of type "InnerObject" (the child):

    public class OuterObject : IValidatableObject
{
    [Required]
    public string OuterObjectName { get; set; }

    public InnerObject FirstInnerObject { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!string.IsNullOrWhiteSpace(OuterObjectName) && string.Equals(OuterObjectName, "test", StringComparison.CurrentCultureIgnoreCase))
        {
            yield return new ValidationResult("OuterObjectName must not be 'test'", new[] { "OuterObjectName" });
        }
    }
}

Here is InnerObject:

    public class InnerObject : IValidatableObject
{
    [Required]
    public string InnerObjectName { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!string.IsNullOrWhiteSpace(InnerObjectName) && string.Equals(InnerObjectName, "test", StringComparison.CurrentCultureIgnoreCase))
        {
            yield return new ValidationResult("InnerObjectName must not be 'test'", new[] { "InnerObjectName" });
        }
    }
}

You will notice the validation I put on both.. just some dummy validation to say some value can't equal "test".

Here is the view that this will display in (Index.cshtml):

@model MvcNestedObjectTest.Models.OuterObject
@{
    ViewBag.Title = "Home Page";
}

@using (Html.BeginForm()) {
<div>
    <fieldset>
        <legend>Using "For" Lambda</legend>

        <div class="editor-label">
            @Html.LabelFor(m => m.OuterObjectName)
        </div>
        <div class="editor-field">
            @Html.TextBoxFor(m => m.OuterObjectName)
            @Html.ValidationMessageFor(m => m.OuterObjectName)
        </div>

        <div class="editor-label">
            @Html.LabelFor(m => m.FirstInnerObject.InnerObjectName)
        </div>
        <div class="editor-field">
            @Html.TextBoxFor(m => m.FirstInnerObject.InnerObjectName)
            @Html.ValidationMessageFor(m => m.FirstInnerObject.InnerObjectName)
        </div>

        <p>
            <input type="submit" value="Test Submit" />
        </p>
    </fieldset>
</div>
}

..and finally here is the HomeController:

    public class HomeController : Controller
{
    public ActionResult Index()
    {
        var model = new OuterObject();
        model.FirstInnerObject = new InnerObject();
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(OuterObject model)
    {
        if (ModelState.IsValid)
        {
            return RedirectToAction("Index");
        }
        return View(model);
    }
}

What you will find is that when the model gets validated by the DefaultModelBinder, the "Validate" method in "InnerObject" gets hit twice, but the "Validate" method in "OuterObject" does not get hit at all.

If you take off IValidatableObject from "InnerObject", then the one on "OuterObject" will get hit.

Is this a bug, or should I expect it to work that way? If I should expect it to, what's the best workaround?

Quentin answered 20/3, 2012 at 6:52 Comment(0)
Q
1

This answer is just to provide one workaround I have just thought of - so it is not really an answer! I am still not sure if this is a bug or what the best workaround is, but here is one option.

If you remove the custom validation logic from "InnerObject" and incorporate it into "OuterObject" it seems to work fine. So basically this works around the bug by only allowing the top-most object to have any custom validation.

Here is the new InnerObject:

    //NOTE: have taken IValidatableObject off as this causes the issue - we must remember to validate it manually in the "Parent"!
public class InnerObject //: IValidatableObject
{
    [Required]
    public string InnerObjectName { get; set; }
}

And here is the new OuterObject (with the Validation code stolen from InnerObject):

    public class OuterObject : IValidatableObject
{
    [Required]
    public string OuterObjectName { get; set; }

    public InnerObject FirstInnerObject { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!string.IsNullOrWhiteSpace(OuterObjectName) && string.Equals(OuterObjectName, "test", StringComparison.CurrentCultureIgnoreCase))
        {
            yield return new ValidationResult("OuterObjectName must not be 'test'", new[] { "OuterObjectName" });
        }

        if (FirstInnerObject != null)
        {
            if (!string.IsNullOrWhiteSpace(FirstInnerObject.InnerObjectName) &&
                string.Equals(FirstInnerObject.InnerObjectName, "test", StringComparison.CurrentCultureIgnoreCase))
            {
                yield return new ValidationResult("InnerObjectName must not be 'test'", new[] { "FirstInnerObject.InnerObjectName" });
            }
        }
    }
}

This works as I would expect, hooking up the validation error to each field correctly.

It is not a great solution because if I need to nest "InnerObject" in some other class, it does not share that validation - I need to replicate it. Obviously I could have a method on the class to store the logic, but each "parent" class needs to remember to "Validate" the child class.

Quentin answered 20/3, 2012 at 21:3 Comment(0)
M
1

I am not sure this is a problem with MVC 4 anymore, but...

If you use partial views made just for your InnerObjects, they will validate correctly.

<fieldset>
    <legend>Using "For" Lambda</legend>

    <div class="editor-label">
        @Html.LabelFor(m => m.OuterObjectName)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(m => m.OuterObjectName)
        @Html.ValidationMessageFor(m => m.OuterObjectName)
    </div>

    @Html.Partial("_InnerObject", Model.InnerObject)

    <p>
        <input type="submit" value="Test Submit" />
    </p>
</fieldset>

Then add this partial "_InnerObject.cshtml":

@model InnerObject

    <div class="editor-label">
        @Html.LabelFor(m => m.InnerObjectName)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(m => m.InnerObjectName)
        @Html.ValidationMessageFor(m => m.InnerObjectName)
    </div>
Mcmorris answered 1/2, 2013 at 21:1 Comment(0)
B
0

Should you have made OuterObject base class for InnerObject instead of creating a relationship as you did? (Or vice versa) and provide the view the base object as the ViewModel?

This will mean that when model binding the default constructor of the OuterObject (or which ever class is your base) will be called indirectly invoking Validate on both objects.

i.e. Class:

public class OuterObject : InnerObject, IValidateableObject
{
...
}

View:

@model MvcNestedObjectTest.Models.OuterObject

Controller Action:

public ActionResult Index(OuterObject model)
Boldt answered 20/3, 2012 at 8:6 Comment(3)
Thanks I had thought of that and it would work for this particular situation, but as teh object gets more complicated it will not work. For example, if I need InnerObject1, SomeString, InnerObject2, SomeOtherString (I.e. the nested objects between other properties)..Quentin
@Quentin Have you tried Fluent Validation which is an advanced way for validating dependencies and nested validation rules?Boldt
I have looked into that and it looks nice, but in our organisation we have chosen to go with the built in data annotations as a standard for validating Models in MVC. I guess we could use a combination, but it still doesn't get around the fact that this does not behave as expected, it's a bit of a trap.Quentin

© 2022 - 2024 — McMap. All rights reserved.