Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

MVC and EF Validation with added ValidationContext item

I have a scenario where I'd like to add an item to the ValidationContext and check for it in the EF triggered entity validation. I'm doing this in a wizard so I can only validate certain things on specific steps. (If there's a good pattern for that please do share it).

The problem is that the validation is triggered, twice actually, before the controller action is even hit. I wish I understood why. I'm not sure how to get the item in ValidationContext before that happens, so I can't tell the validation what step I'm on.

Furthermore, if I only do the custom validation when save changes is triggered by checking for the item as I have in my code below, then I get no automatic model validation errors displayed when the page refreshes.

In my custom context:

public WizardStep Step { get; set; }

protected override DbEntityValidationResult ValidateEntity(DbEntityEntry entityEntry, IDictionary<object, object> items)
{
    items.Add("ValidationStep", Step);
    return base.ValidateEntity(entityEntry, items);
}

Service that sets the entity:

public void SaveChanges(WizardStep step)
{
    _context.Step = step;
    _context.SaveChanges();
}

In my entity

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
    // Step will only be present when called from save changes.  Calls from model state validation won't have it
    if (validationContext.Items.ContainsKey("ValidationStep"))
    {
        var validationStep = (WizardStep)validationContext.Items["ValidationStep"];
        if (validationStep == WizardStep.Introduction)
        {
            if (criteria)
            {
                yield return new ValidationResult($"Error message  ", new[] { "field" });
            }
        }
    }
}

Controller:

public ActionResult MyAction(HomeViewModel vm)
{
    try
    {
        _incidentService.AddOrUpdate(vm.Enttiy);
        _incidentService.SaveChanges(WizardStep.Introduction);
    }
    catch (Exception ex)
    {
        return View(vm);
    }
    return RedirectToAction("Index");
}
like image 701
Alex Avatar asked Mar 13 '17 21:03

Alex


1 Answers

The first validation is on the MVC created model that is passed to the controller. MVC uses a ModelBinder class to construct, populate and validate the client http form data into the model. Any failed validation will be returned to the client. A valid model may then be changed by the controller, so a second validation is done by EF when saved. I believe when saved, EF validation is only triggered if the property is new or has different data the original value.

Theoretically it should be possible to have a custom MVC ModelValidator and intercept the Validate method to set ValidationContext items. However, I could NOT figure out how to do that. I did however find a slightly different solution that works for me. Perhaps it can be adapted to fit your needs.

In my case, I wanted the EF DbContext (In my code its named CmsEntities) available to the Validation methods so I can querying the database (and do rich complex business logic validation). The controller has the DbContext, but the model validation is called by the ModelBinder before passing it to the controller’s action.

My solution is to:

1) Add a DbContext property to my Entity (Using partial class, or in Base Entity that all entities inherit from)

2) Create a Custom ModelBinder that will get the DbContext from the controller and populate it to the model

3) Register the Custom ModelBinder in the Application_Start()

Now, inside any validation method, the model will have a populated DbContext. 

Custom ModelBinder

public class CmsModelBinder : DefaultModelBinder
{
    protected override bool OnModelUpdating(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        // Copy CmsEntities from Controller to the Model (Before we update and validate the model)
        var modelPropertyInfo = bindingContext.Model.GetType().GetProperty("CmsEntities");
        if (modelPropertyInfo != null)
        {
            var controllerPropertyInfo = controllerContext.Controller.GetType().GetProperty("CmsEntities");
            if (controllerPropertyInfo != null)
            {
                CmsEntities cmsEntities = controllerPropertyInfo.GetValue(controllerContext.Controller) as CmsEntities;
                modelPropertyInfo.SetValue(bindingContext.Model, cmsEntities);
            }
        }            
        return base.OnModelUpdating(controllerContext, bindingContext);
    }

Global.asax.cs

    protected void Application_Start()
    {
        ...
        ModelBinders.Binders.DefaultBinder = new CmsModelBinder();
    }
like image 183
RitchieD Avatar answered Nov 13 '22 19:11

RitchieD