What is YOUR quest? ASP.NET MVC 2 Model Binding and Conditional Validation
In my MVC project, I had a model object that had a couple of foreign keys. These were represented on the form as drop down lists that returned the ID of the item selected. The one that was causing all of the problems was "Status", so I’ll simply refer to that one from here on out.
The form would post the ID of the Status selected. My model class, however, wasn’t expecting an ID, though – it was a Castle ActiveRecord object, so it wanted an object of type "Status". After a few failed attempts to try to convert the ID to an object in the controller action, I finally stumbled onto using a custom model binder. This was a class that you attach to your model object (via a class-level attribute), and provides methods you can override to perform custom binding.
So, first, a simplified version of my model class:
Imports System.ComponentModel.DataAnnotations <ModelBinder(GetType(EventBinder))> _ Public Class EventDetail Private _CurrentEvent As BusinessServices.CorporateEvent Public ReadOnly Property ID() As Long Get If (IsNothing(Me.CurrentEvent)) Then Return 0 Return Me.CurrentEvent.ID End Get End Property Public Property CurrentEvent() As BusinessServices.CorporateEvent Get Return Me._CurrentEvent End Get Set(ByVal value As CorporateEvent) Me._CurrentEvent = value End Set End Property End Class
And the initial model binder I used:
Public Class EventBinder Inherits DefaultModelBinder Public Overrides Function BindModel(ByVal controllerContext As System.Web.Mvc.ControllerContext, ByVal bindingContext As System.Web.Mvc.ModelBindingContext) As Object Dim MyModel As EventDetail MyModel = CType(MyBase.BindModel(controllerContext, bindingContext), EventDetail) If (controllerContext.HttpContext.Request.Form.AllKeys.Contains("CurrentEvent.EventStatus.StatusID")) Then MyModel.CurrentEvent.EventStatus = Status.GetByID(Convert.ToInt64(controllerContext.HttpContext.Request.Form("CurrentEvent.EventStatus.StatusID"))) End If ' Do some other custom binding work here...
Return MyModel End Function End Class
I set a breakpoint in both my controller action that handled the postback, and in EventBinder.BindModel. What I found was that BindModel happened before my controller action was event invoked. That explained why all of my attempts to capture this in the controller action failed (in particular, the object was always invalid by the time the controller action got ahold of it). The binding was simply happening earlier in the call stack. So, using the custom binder, I could translate ID to object.
And there was much rejoicing.
Fast forward a couple of weeks, and I found myself pressing my luck. All of the validation that I had done up to this point was using the out-of-the-box "Required" attributes on the CorporateEvent business class properties. For example:
Imports Castle.ActiveRecord Imports System Imports System.ComponentModel.DataAnnotations Imports System.ComponentModel <ActiveRecord(Table:="CorporateEvent"), _ Serializable()> _ Public Class CorporateEvent Inherits ActiveRecordBase(Of CorporateEvent) Private _ID As Long Private _Title As String Private _OccursOn As DateTime Private _EventStatus As Status <PrimaryKey("ID")> _ Public Property ID() As Long Get Return Me._ID End Get Set(ByVal value As Long) Me._ID = value End Set End Property <[Property](), _ Required(ErrorMessage:="Title is required."), _ StringLength(200, ErrorMessage:="Maximum event title length is 200 characters.")> _ Public Property Title() As String Get Return Me._Title End Get Set(ByVal value As String) Me._Title = value End Set End Property <[Property](), _ Required(ErrorMessage:="Occurs On date/time is required.")> _ Public Property OccursOn() As DateTime Get Return Me._OccursOn End Get Set(ByVal value As DateTime) Me._OccursOn = value End Set End Property <BelongsTo("StatusID"), _ DisplayName("Status")> _ Public Property EventStatus() As Status Get Return Me._EventStatus End Get Set(ByVal value As Status) Me._EventStatus = value End Set End Property End Class
Now what I wanted to do was have specific fields in my model BECOME required when the Status was switched to Published. In other words, I wanted "conditional validation". I found a wonderful post and code sample from Simon Ince that does just that. He creates a custom attribute called RequiredIf that makes the property required if another property has the named value. For example:
Imports Castle.ActiveRecord Imports System Imports System.ComponentModel.DataAnnotations Imports System.ComponentModel Imports ConditionalValidation.Validation <ActiveRecord(Table:="CorporateEvent"), _ Serializable()> _ Public Class CorporateEvent ... <[Property](), _ RequiredIf("IsPublished", True, ErrorMessage:="Details Title is required for Published events."), _ StringLength(100, ErrorMessage:="Maximum length is 100 characters.")> _ Public Property TitleDetails() As String Get Return Me._TitleDetails End Get Set(ByVal value As String) Me._TitleDetails = value End Set End Property Public ReadOnly Property IsPublished() As Boolean Get If (IsNothing(Me.EventStatus)) Then Return False Return (Me.EventStatus.ID = Status.Statuses.Published) End Get End Property End Class
So, I took his logic, and dropped it into a new project in my solution. The property I wanted to evaluate was EventStatus.ID, but the attribute didn’t know how to evaluate "EventStatus.ID", so I created a boolean readonly property in CorporateEvent called "IsPublished" that did this evaluation for me. I could now compare against True and False in the RequiredIf attribute.
Finally, I had to register the attribute in my Global.asax / Application_Start routine, like so:
I wrote a unit test for one of the CorporateEvent properties (where EventStatus was Published, but my property wasn’t specified yet), and ran it. Normally, the logic should have thrown it out with a validation error, but it did not. Great! My test failed! I then wired up the RequiredIf attribute to that property, and re-ran the test. It still failed.
Oh, duh. I didn’t register the custom attribute in my test fixture – no wonder it wasn’t recognizing it. I copied the same registration line I used in Application_Start to my TestFixtureSetUp method. I ran the test again. It still failed.
And so began two days of gesturing at the monitor.
I won’t bore you with all of my failed attempts and rabbit trails. At the beginning of the second day, however, I realized that the only way I was going to get this thing to work is by reverting everything I had done up to that point, and starting over – this time dropping the pieces into the project in much smaller increments so I could see where it failed. (Did I mention that I had dozens of tests breaking by the end of my initial pass at this, and I hadn’t the foggiest idea WHY they were breaking?!?)
By the time I had my breakthrough at the end of the second day, I was VERY close to scrapping the whole approach and doing the RequiredIf validation in the controller action. I kept forcing myself forward, though, because I really wanted to do it right, and there didn’t seem to be any good reason why it shouldn’t have worked.
The breakthrough was a matter of timing.
In tracing through the logic, and inspecting the model each step of the way, I finally realized that my IsPublished property was never being set to True in time for the RequiredIf evaluation to happen. As a result, IsPublished was always False, and so my custom validation was being skipped. The reason it wasn’t being set in time was because EventStatus was being bound in EventBinder.BindModel, but the evaluation of RequiredIf was being done sometime before that point. It was then that I started looking at what other functions I could override in EventBinder. I overrode several that sounded like they might work, and put breakpoints in each to see the order that they get called and to inspect my model to see when things got bound.
What I found was that the CreateModel function got called first, and sets up the basic model object. So, I tried moving the EventStatus logic from BindModel to CreateModel:
Public Class EventBinder Inherits DefaultModelBinder Protected Overrides Function CreateModel(ByVal controllerContext As System.Web.Mvc.ControllerContext, ByVal bindingContext As System.Web.Mvc.ModelBindingContext, ByVal modelType As System.Type) As Object Dim NewModel As Object ' Create the new model object from scratch NewModel = MyBase.CreateModel(controllerContext, bindingContext, modelType) NewModel.CurrentEvent = New CorporateEvent ' Set EventStatus here If (controllerContext.HttpContext.Request.Form.AllKeys.Contains("CurrentEvent.EventStatus.StatusID")) Then NewModel.CurrentEvent.EventStatus = Status.GetByID(Convert.ToInt64(controllerContext.HttpContext.Request.Form("CurrentEvent.EventStatus.StatusID"))) End If Return NewModel End Function Public Overrides Function BindModel(ByVal controllerContext As System.Web.Mvc.ControllerContext, ByVal bindingContext As System.Web.Mvc.ModelBindingContext) As Object Dim MyModel As EventDetail MyModel = CType(MyBase.BindModel(controllerContext, bindingContext), EventDetail) ' Do some other custom binding work here... Return MyModel End Function End Class
Now when it came time to evaluate my RequiredIf attribute, IsPublished had the right value, and could be properly evaluated.
And there was much rejoicing.
Despite the profound amount of grief this approach gave me (most of it being self-inflected), the RequiredIf attribute works very well and saves a lot of time when adding new fields to my model class. I also learned quite a bit about how model binding and validation works in MVC, so looking back it really was worth the effort.
But man, what a quest.
No comments yet.