I have the following ViewModel:
public class StayDetails
{
public int NumberOfRooms { get; set; }
public IList<RoomDetail> Rooms { get;set; }
}
public class RoomDetail
{
public int RoomNumber { get; set; }
[MinIfRoomRequired("StayDetails.NumberOfRooms", "RoomNumber", 1]
public int NumberOfAdults { get;set; }
}
What I am trying to do is create a custom validator which will validate the number of adults in a room and make sure that there is at least 1, but only if the current room is required. This is known by looking at the NumberOfRooms property on the StayDetails object.
My custom validator so far:
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
// get a reference to the depended properties
var containerType = validationContext.ObjectInstance.GetType();
var requiredRoomsField = containerType.GetProperty(RequiredRoomsPropertyName);
var roomNumberField = containerType.GetProperty(RoomNumberPropertyName);
if (requiredRoomsField != null && roomNumberField != null)
{
// get the value of the dependent properties
var requiredRoomsValue = requiredRoomsField.GetValue(validationContext.ObjectInstance, null);
var roomNumberValue = roomNumberField.GetValue(validationContext.ObjectInstance, null);
... (remaining logic to test values) ...
The problem I have is that I cannot access the NumberOfRooms property, the validationContext.ObjectInstance does not have any refernece to the parent object. I thought about adding a reference to the StayDetails object onto the RoomDetails object during object initialzation so I can reference the property from there but model binding wont allow that as the RoomDetail object does not have a parameterless constructor.
Any suggestions?
Many thanks,
David
You should define validation annotation on StayDetails class
instead of RoomDetail. This way you will have all the values, NumberOfRooms, list of rooms and their respective RoomNumber and NumberOfAdults. Change your validator accordingly.
You can try doing it with the FluentValidation PropertyValidator.
Write less do more...
I was able to solve this problem by using custom binders. You will need to add a property to refer back to the parent object, such as, NumberOfRooms. In my case, I actually created a delegate that referred back to a routine in the parent object. Apologies in advance for the VB code and formatting issues I'm having with stackoverflow.
- Create the NumberOfRooms property on the child object.
- Create a custom model binder for the child object class. This model binder will do a couple of things:
a) Insert a value for the NumberOfRooms (in my case, I set a delegate)
b) Store the metadata/key information in the controller dictionary to be used later to revalidate.
For example:
Public Class RoomDetail_Binder
Inherits DefaultModelBinder
Protected Overrides Function CreateModel(controllerContext As ControllerContext, bindingContext As ModelBindingContext, modelType As Type) As Object
Dim theObj As Quote_Equipment_Model = MyBase.CreateModel(controllerContext, bindingContext, modelType)
Dim theParent As StayDetails= controllerContext.HttpContext.Items("StayDetails")
If Not IsNothing(theParent) Then
theObj.NumberOfRooms=theParent.NumberOfRooms
End If
Return theObj
End Function
Protected Overrides Sub OnModelUpdated(controllerContext As ControllerContext, bindingContext As ModelBindingContext)
MyBase.OnModelUpdated(controllerContext, bindingContext)
Dim theMetadataList As List(Of MetaDataPair)
If Not controllerContext.HttpContext.Items.Contains("MetadataList") Then
theMetadataList = New List(Of MetaDataPair)
controllerContext.HttpContext.Items.Add("MetadataList", theMetadataList)
Else
theMetadataList = controllerContext.HttpContext.Items("MetadataList")
End If
theMetadataList.Add(New MetaDataPair With {.Metadata = bindingContext.ModelMetadata, .BindingModelName = bindingContext.ModelName})
End Sub
End Class
Note that MetadataList is simply
Public Class MetaDataPair
Public Property BindingModelName As String
Public Property Metadata As ModelMetadata
End Class
Next I create a custom binder for the parent object: This also does a couple things:
a) Store the parent object in the controllercontext so that it can be used by the child object.
b) Re-validate the child object.
Public Class StayDetails_Binder
Inherits DefaultModelBinder
Protected Overrides Function CreateModel(controllerContext As ControllerContext, bindingContext As ModelBindingContext, modelType As Type) As Object
Dim theObj As StayDetails = MyBase.CreateModel(controllerContext, bindingContext, modelType)
controllerContext.HttpContext.Items("StayDetails") = theObj
Return theObj
End Function
Public Overrides Function BindModel(controllerContext As ControllerContext, bindingContext As ModelBindingContext) As Object
Dim theObj As StayDetails = MyBase.BindModel(controllerContext, bindingContext)
Dim theMetadataList As List(Of MetaDataPair) = CType(controllerContext.HttpContext.Items("MetadataList"), List(Of MetaDataPair))
For Each Metadata In theMetadataList
For Each result As ModelValidationResult In ModelValidator.GetModelValidator(Metadata.Metadata, controllerContext).Validate(Nothing)
Dim key As String = CreateSubPropertyName(Metadata.BindingModelName, result.MemberName)
If Not bindingContext.ModelState(key).Errors.Any(Function(ent) ent.ErrorMessage = result.Message) Then
bindingContext.ModelState.AddModelError(key, result.Message)
End If
Next
Next
Return theObj
End Function
End Class
Decorate your subclass appropriately
<ModelBinder(GetType(RoomDetail_Binder))>
public class RoomDetail
Set your controller so that it will use the custom binder:
<ModelBinder(GetType(StayDetails_Binder))> (your parameter name here)
Make certain your validator is set to SUCCESS if you validate it without the NumberOfRooms property filled. The binding will execute your validators twice for your child class. First time before the property filled, and then again when the property after it's filled.