December 10, 2009 2:00 PM by Daniel Chambers
I was working on some ASP.NET MVC code today and I created this neat little solution that uses a custom model binder to automatically read in a bunch of dynamically created form fields and project their data into a set of business entities which were returned by the model binder as a parameter to an action method. The model binder isn't particularly complex, but the way I used regular expressions and LINQ to identify and collate the fields I needed to create the list of entities from was really neat and cool.
I wrote a Javascript-powered form (using jQuery) that allowed the user to add and edit multiple items at the same time, and then submit the set of items in a batch to the server for a save. The entity these items represented looks like this (simplified):
public class CostRangeItem { public short VolumeUpperBound { get; set; } public decimal Cost { get; set; } }
The Javascript code, which I won't go into here as it's not particularly cool (just a bit of jQuery magic), creates form items that look like this:
<input id="VolumeUpperBound:0" name="VolumeUpperBound:0" type="text /> <input id="Cost:0" name="Cost:0" type="text" />
The multiple items are handled by adding more form fields and incrementing the number after the ":" in the id/name. So having fields for two items means you end up with this:
<input id="VolumeUpperBound:0" name="VolumeUpperBound:0" type="text /> <input id="Cost:0" name="Cost:0" type="text" /> <input id="VolumeUpperBound:1" name="VolumeUpperBound:1" type="text /> <input id="Cost:1" name="Cost:1" type="text" />
I chose to use the weird ":n" format instead of a more obvious "[n]" format (like arrays) since using "[" is not valid in an ID attribute and you end up with a page that doesn't validate.
I created a model binder class that looked like this:
private class CostRangeItemBinder : IModelBinder { public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) { ... } }
To use the model binder on an action method, you use the ModelBinderAttribute to specify the type of model binder you want ASP.NET MVC to bind the parameter with:
public ActionResult Create([ModelBinder(typeof(CostRangeItemBinder))] IList<CostRangeItem> costRangeItems) { ... }
The first thing I did was create two regular expressions so that I could identify which fields I needed to read in (the VolumeUpperBound ones and the Cost ones). I chose to use regular expressions because they're much simpler to write (once you learn them!) than some manual string hacking code, and I could use them to not only identify which fields I needed to read, but also to extract the number after the ":" so I could link up the appropriate VolumeUpperBound field with the appropriate Cost field to create a CostRangeItem object. These are the regular expressions I used:
private static readonly Regex _VolumeUpperBoundRegex = new Regex(@"^VolumeUpperBound:(\d+)$"); private static readonly Regex _CostRegex = new Regex(@"^Cost:(\d+)$");
Both regexs work like this: the ^ at the beginning and the $ at the end means that, in the matching string, there cannot be any characters before or after the characters specified in between those symbols. It effectively anchors the match to the beginning and the end of the string. Inside those symbols, each regex matches their field name and the ":" character. They then define a special "group" using the ()s. Using the group allows me to pull out the sub-part of the regex match defined by this group later on. The \d+ means match one or more digits (0-9), which matches the number after the ":".
Note that I've made the Regex objects static readonly members of my binder class. Keeping a single instance of the regex object, which is immutable, saves .NET from having to recompile a new Regex every time I bind, which I believe is a relatively expensive process.
Then, in the BindModel method, I first make sure that the model binder has been used on the correct type in the action method:
if (bindingContext.ModelType.IsAssignableFrom(typeof(List<CostRangeItem>)) == false) return null;
I then use LINQ to perform matches against all the fields in the current page submit (via the BindingContext's ValueProvider IDictionary). I do this using a Select() call, which performs the match and puts the results into an anonymous class. I then filter out any fields that didn't match the regex using a Where() call.
var vubMatches = bindingContext.ValueProvider .Select(kvp => new { Match = _VolumeUpperBoundRegex.Match(kvp.Key), Value = kvp.Value.AttemptedValue }) .Where(o => o.Match.Success); var costMatches = bindingContext.ValueProvider .Select(kvp => new { Match = _CostRegex.Match(kvp.Key), Value = kvp.Value.AttemptedValue }) .Where(o => o.Match.Success);
Now I need to link up VolumeUpperBound fields with their associated Cost fields. To do this, I do an inner equijoin using LINQ. I join on the result of that regex group that I created that extracts the number from the field name. This means I join VolumeUpperBound:0 with Cost:0, and VolumeUpperBound:1 with Cost:1 and so on.
List<CostRangeItem> costRangeItems = (from vubMatch in vubMatches join costMatch in costMatches on vubMatch.Match.Groups[1].Value equals costMatch.Match.Groups[1].Value where ValuesAreValid(vubMatch.Value, costMatch.Value, controllerContext) select new CostRangeItem { Cost = Decimal.Parse(costMatch.Value), VolumeUpperBound = Int16.Parse(vubMatch.Value) } ).ToList(); return costRangeItems;
I then use a where clause to ensure the values submitted are actually of the correct type, since the Value member is actually a string at this point (I pass them into a ValuesAreValid method, which returns a bool). This method marks the ModelState with an error if it finds a problem. I then use Select to create a CostRangeItem per join and copy in the values from the form fields using an object initialiser. I can then finally return my List of CostRangeItem. The ASP.NET MVC framework ensures that this result is passed to the action method that declared that it wanted to use this model binder.
As you can see, the solution ended up being a neat declarative way of writing a model binder that can pull in and bind multiple objects from a dynamically created form. I didn't have to do any heavy lifting at all; regular expressions and LINQ handled all the munging of the data for me! Super cool stuff.