Scott Hanselman

Splitting DateTime - Unit Testing ASP.NET MVC Custom Model Binders

February 26, '09 Comments [27] Posted in ASP.NET | ASP.NET MVC
Sponsored By

I've got this form for users to create an event. One of the fields is a DateTime, like this:

image

And that's kind of lame as it's hard to type in a Date and a Time at the same time. It's also no fun. Most sites would have those separated, and ideally use some kind of Calendar Picker with jQuery or something.

image

But when you post a form like this back to a Controller Action, it doesn't exactly line up neatly with a System.DateTime object. There's no clean way to get partials like this and combine them into a single DateTime. The "ViewModel" in doesn't match the Model itself. I could certainly make my method take two DateTimes, along with the other fields, then put them together later. It could get mess though, if I split things up even more, like some travel sites do with Month, Date, Year each in separate boxes, then Hours, Minutes, and Seconds off in their own.

image

I figured this might be a decent place for a custom "DateAndTimeModelBinder" after my last attempt at a Model Binder was near-universally panned. ;)

Here's my thoughts, and I'm interested in your thoughts as well, Dear Reader.

DateAndTimeModelBinder

First, usage. You can either put this Custom Model Binder in charge of all your DateTimes by registering it in the Global.asax:

ModelBinders.Binders[typeof(DateTime)] = 
new DateAndTimeModelBinder() { Date = "Date", Time = "Time" };

The strings there are the suffixes of the fields in your View that will be holding the Date and the Time. There are other options in there like Hour, Minute, you get the idea.

Instead of my View having a date in one field:

<label for="EventDate">Event Date:</label>
<%= Html.TextBox("EventDate", Model.Dinner.EventDate) %>

I split it up, and add my chosen suffixes:

<label for="EventDate">Event Date:</label>
<%= Html.TextBox("EventDate.Date", Model.Dinner.EventDate.ToShortDateString()) %>
<%= Html.TextBox("EventDate.Time", Model.Dinner.EventDate.ToShortTimeString()) %>

Now, when the Form is POST'ed back, no one is the wiser, and the model is unchanged:

[AcceptVerbs(HttpVerbs.Post), Authorize]
public ActionResult Create(Dinner dinnerToCreate) {
//The two fields are now inside dinnerCreate.EventDate
// and model validation runs as before...
}

That's the general idea. You can also just put the attribute on a specific parameter, like this:

public ActionResult Edit(int id, 
[DateAndTime("year", "mo", "day", "hh","mm","secondsorhwatever")]
DateTime foo) {
...yada yada yada...
}

It's so nice, that I give it the Works On My Machine Seal:

Here's the code, so far. It's longish. I'm interested in your opinions on how to make it clearer, cleaner and DRYer (without breaking the tests!)

NOTE: If you're reading this via RSS, the code will be syntax highlighted and easier to read if you visit this post on my site directly.

public class DateAndTimeModelBinder : IModelBinder
{
public DateAndTimeModelBinder() { }

public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException("bindingContext");
}

//Maybe we're lucky and they just want a DateTime the regular way.
DateTime? dateTimeAttempt = GetA<DateTime>(bindingContext, "DateTime");
if (dateTimeAttempt != null)
{
return dateTimeAttempt.Value;
}

//If they haven't set Month,Day,Year OR Date, set "date" and get ready for an attempt
if (this.MonthDayYearSet == false && this.DateSet == false)
{
this.Date = "Date";
}

//If they haven't set Hour, Minute, Second OR Time, set "time" and get ready for an attempt
if (this.HourMinuteSecondSet == false && this.TimeSet == false)
{
this.Time = "Time";
}

//Did they want the Date *and* Time?
DateTime? dateAttempt = GetA<DateTime>(bindingContext, this.Date);
DateTime? timeAttempt = GetA<DateTime>(bindingContext, this.Time);

//Maybe they wanted the Time via parts
if (this.HourMinuteSecondSet)
{
timeAttempt = new DateTime(
DateTime.MinValue.Year, DateTime.MinValue.Month, DateTime.MinValue.Day,
GetA<int>(bindingContext, this.Hour).Value,
GetA<int>(bindingContext, this.Minute).Value,
GetA<int>(bindingContext, this.Second).Value);
}

//Maybe they wanted the Date via parts
if (this.MonthDayYearSet)
{
dateAttempt = new DateTime(
GetA<int>(bindingContext, this.Year).Value,
GetA<int>(bindingContext, this.Month).Value,
GetA<int>(bindingContext, this.Day).Value,
DateTime.MinValue.Hour, DateTime.MinValue.Minute, DateTime.MinValue.Second);
}

//If we got both parts, assemble them!
if (dateAttempt != null && timeAttempt != null)
{
return new DateTime(dateAttempt.Value.Year,
dateAttempt.Value.Month,
dateAttempt.Value.Day,
timeAttempt.Value.Hour,
timeAttempt.Value.Minute,
timeAttempt.Value.Second);
}
//Only got one half? Return as much as we have!
return dateAttempt ?? timeAttempt;
}

private Nullable<T> GetA<T>(ModelBindingContext bindingContext, string key) where T : struct
{
if (String.IsNullOrEmpty(key)) return null;
ValueProviderResult valueResult;
//Try it with the prefix...
bindingContext.ValueProvider.TryGetValue(bindingContext.ModelName + "." + key, out valueResult);
//Didn't work? Try without the prefix if needed...
if (valueResult == null && bindingContext.FallbackToEmptyPrefix == true)
{
bindingContext.ValueProvider.TryGetValue(key, out valueResult);
}
if (valueResult == null)
{
return null;
}
return (Nullable<T>)valueResult.ConvertTo(typeof(T));
}
public string Date { get; set; }
public string Time { get; set; }

public string Month { get; set; }
public string Day { get; set; }
public string Year { get; set; }

public string Hour { get; set; }
public string Minute { get; set; }
public string Second { get; set; }

public bool DateSet { get { return !String.IsNullOrEmpty(Date); } }
public bool MonthDayYearSet { get { return !(String.IsNullOrEmpty(Month) && String.IsNullOrEmpty(Day) && String.IsNullOrEmpty(Year)); } }

public bool TimeSet { get { return !String.IsNullOrEmpty(Time); } }
public bool HourMinuteSecondSet { get { return !(String.IsNullOrEmpty(Hour) && String.IsNullOrEmpty(Minute) && String.IsNullOrEmpty(Second)); } }

}

public class DateAndTimeAttribute : CustomModelBinderAttribute
{
private IModelBinder _binder;

// The user cares about a full date structure and full
// time structure, or one or the other.
public DateAndTimeAttribute(string date, string time)
{
_binder = new DateAndTimeModelBinder
{
Date = date,
Time = time
};
}

// The user wants to capture the date and time (or only one)
// as individual portions.
public DateAndTimeAttribute(string year, string month, string day,
string hour, string minute, string second)
{
_binder = new DateAndTimeModelBinder
{
Day = day,
Month = month,
Year = year,
Hour = hour,
Minute = minute,
Second = second
};
}

// The user wants to capture the date and time (or only one)
// as individual portions.
public DateAndTimeAttribute(string date, string time,
string year, string month, string day,
string hour, string minute, string second)
{
_binder = new DateAndTimeModelBinder
{
Day = day,
Month = month,
Year = year,
Hour = hour,
Minute = minute,
Second = second,
Date = date,
Time = time
};
}

public override IModelBinder GetBinder() { return _binder; }
}

Testing the Custom Model Binder

It works for Dates or Times, also. If you just want a Time, you'll get a MinDate, and if you just want a Date, you'll get a Date at midnight.

Here's just two of the tests. Note I was able to test this custom Model Binder without any mocking (thanks Phil!)

Some custom model binders do require mocking if they go digging around in the HttpContext or other concrete places. In this case, I just needed to poke around in the Form, so it was cleaner to use the existing ValueProvider.

[TestMethod]
public void Date_Can_Be_Pulled_Via_Provided_Month_Day_Year()
{
var dict = new ValueProviderDictionary(null) {
{ "foo.month", new ValueProviderResult("2","2",null) },
{ "foo.day", new ValueProviderResult("12", "12", null) },
{ "foo.year", new ValueProviderResult("1964", "1964", null) }
};

var bindingContext = new ModelBindingContext() { ModelName = "foo", ValueProvider = dict};

DateAndTimeModelBinder b = new DateAndTimeModelBinder() { Month = "month", Day = "day", Year = "year" };

DateTime result = (DateTime)b.BindModel(null, bindingContext);
Assert.AreEqual(DateTime.Parse("1964-02-12 12:00:00 am"), result);
}

[TestMethod]
public void DateTime_Can_Be_Pulled_Via_Provided_Month_Day_Year_Hour_Minute_Second_Alternate_Names()
{
var dict = new ValueProviderDictionary(null) {
{ "foo.month1", new ValueProviderResult("2","2",null) },
{ "foo.day1", new ValueProviderResult("12", "12", null) },
{ "foo.year1", new ValueProviderResult("1964", "1964", null) },
{ "foo.hour1", new ValueProviderResult("13","13",null) },
{ "foo.minute1", new ValueProviderResult("44", "44", null) },
{ "foo.second1", new ValueProviderResult("01", "01", null) }
};

var bindingContext = new ModelBindingContext() { ModelName = "foo", ValueProvider = dict };

DateAndTimeModelBinder b = new DateAndTimeModelBinder() { Month = "month1", Day = "day1", Year = "year1", Hour = "hour1", Minute = "minute1", Second = "second1" };

DateTime result = (DateTime)b.BindModel(null, bindingContext);
Assert.AreEqual(DateTime.Parse("1964-02-12 13:44:01"), result);
}

Thanks to LeviB for his help. Your thoughts?

About Scott

Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.

facebook twitter subscribe
About   Newsletter
Sponsored By
Hosting By
Dedicated Windows Server Hosting by ORCS Web
Thursday, February 26, 2009 4:45:29 AM UTC
Scott,

I know you thought of it, but why not use the Date and Time data types in SQL 2008 and split it into two fields to begin with?
Thursday, February 26, 2009 5:21:13 AM UTC
Good question. A few reasons, one, the model makes more sense with a Date and Time being together. Two, using a System.DateTime is the most standard and the most portable, again from a Model (or ViewModel point of view). Third, and most important, there isn't really any CLR/BCL data type that I know of that represents just a date. Just because SQL Server has it, doesn't mean it's a CLI thing.
Thursday, February 26, 2009 5:39:20 AM UTC
Just a thought. Maybe sprinkle a few references to CultureInfo here and there? Just in case someone use a date or time format other than US (or whatever system culture the web server is using).
Thursday, February 26, 2009 6:21:24 AM UTC
Kristofer - No need, in the code. The ValueProvider is already handling the CurrentCulture correctly underneath. I *do* however, need to make tests for that. ;)
Thursday, February 26, 2009 7:27:37 AM UTC
First I want to say lol! :) Where did this thought come from? Don't get me wrong it's a very nice solution, actually one that I've been thinking about for some time. Specially with schedules and such it's hard to get the user to input date w/time correctly. Splitting them up but storing them in the same field makes perfect sence. I wouldn't have gone all the way like this though :)
Thursday, February 26, 2009 8:26:52 AM UTC
CatZ - What would you do? In my app, I was just Request.Form["Date"] etc, then manually putting them together.

I mean, I realize it's over the top for one instance, but I'm thinking of asking the MVC team to put it in the MVC Futures stuff for a future release. Is it generic enough and useful enough that you'd use it if you knew it existed?
Thursday, February 26, 2009 9:15:59 AM UTC
@Scott:
I would use it, if will be part of MVC. It's very handy.
I had nearly the same problem on one of my projects. Need to split only date to Listboxes like this "MM / YYYY" and "DD".
I just use hidden field into wchich is date formated by JQuery.DatePicker on listbox changes. My be lame, but it works perfect.

Franta.
Thursday, February 26, 2009 10:01:27 AM UTC
H Scott,

I can't send you email cause registration for i-names is not working so I'm writting here :)

I've found your notepad2 with Ruby syntax built in. Do you know that notepad2 have syntax export/import in the newest version?

Maybe you can create just syntax file for Ruby to import into notepad2? Instead of special builds which you have tu update?

Still great idea to have Ruby in notepad2 :)

Greetings,
Ania
Thursday, February 26, 2009 4:14:08 PM UTC
I've had this issue in our first production MVC app, and may move to this approach. I don't like that it introduces 'death by textboxes' to the UI. I'd much rather see an intuitive jQuery plugin for date-and-time, and let the power user type and the point-and-click user select. But I have yet to find one I like, so this may be what we do.

One other gimmick I like to add to my date uis is support for keywords like 'Today', 'Tomorrow', 'Yesterday', etc. This would be a good way to support that...
Thursday, February 26, 2009 4:27:09 PM UTC
The Idea is cool, but dude where's the weekly podcast? I need my FIX!

Greetings,

Ruddy
Ruddy
Thursday, February 26, 2009 6:31:09 PM UTC
Off the subject but you mention this in your blog and the comment above......"again from a Model (or ViewModel point of view)".

Sounds like you are creating presentation models that aggregate many "business logic" models together for the view. Be interesting to see a post on this with your solution.
James
Thursday, February 26, 2009 6:43:00 PM UTC
Great! You just saved me a job of writing exactly the same thing! Thanks Scott :)
Thursday, February 26, 2009 7:38:11 PM UTC
Ruddy - The editor for my podcast had surgery...should be up soon. I've got two in the queue.
Thursday, February 26, 2009 9:15:18 PM UTC
Awesome!!
Ruddy
Friday, February 27, 2009 5:08:47 PM UTC
Scott,

I really like this approach. I would definitely use this, so long as it worked with other input types like selects and the upcoming date, datetime, time, etc. elements in HTML 5.

Great job!
Friday, February 27, 2009 9:12:50 PM UTC
Sorry, but I just don't get it. That's a boatload of code for that simple of a page. The supposed "simplicity" of the MVC architecture never ceases to amaze me when I look at the lines and lines and lines of code used to do something that's so damn simple in WebForms. MVC is chasing the Java tail, and we're going to end up with as much of as mess as they have.
Friday, February 27, 2009 10:10:57 PM UTC
Walden - I hear what you're saying. This isn't meant for a PAGE, but rather as a reusable component that would be buried and used in a half a dozen other situations. It's true, though, that I'm writing an assload of code to make one line prettier. This isn't MVC's fault though.

This problem CAN be solved in a single line, just pulling the two DateTime fields out of the Request object itself. Blame me, not MVC.
Saturday, February 28, 2009 9:12:25 AM UTC
Scott. You are right when you suggest I would use it if it existed. It's one of those things that make input from end user much easier and bulletproof.
Saturday, February 28, 2009 12:21:48 PM UTC
Where's the summary for non-.Net users? :)

I assume you have some framework that automates form building and validation, and plugs into models to CRUD, but you wanted the form fields to be different from database fields to make for a better web GUI.

I think you'd be better off with one field and the Javascript date picker. If you don't want Javascript (I wouldn't blame you), you could try making the field input parser "smarter" to accept values like "9pm today", but that really depends on what the particular interface is used for etc.

By the way, what do you do if the form has to include a field that don't make sense to keep the value of in the database, i.e. the "I agree to the rules I didn't read" checkbox?
Saturday, February 28, 2009 12:24:08 PM UTC
I think your OpenID comments is borken.
Saturday, February 28, 2009 6:34:15 PM UTC
Ya, Google's OpenID implementation sucks. I am working on it. They don't return your actual NAME.
Saturday, February 28, 2009 8:43:54 PM UTC
Hello, I've got no promised syntax highlighting when viewing on your website directly with provided link... No difference between RSS reader and the website. Otherwise this post is genius. I have to manage to learn this stuff :)
courage_dog
Monday, March 02, 2009 7:49:31 AM UTC
I really like this solution. I do have one question... I tried this solution in my own project. When I use the Attribute in my controller, as such:

public ActionResult Create(FormCollection collection,
[DateAndTime("PublicationDate.Year", "PublicationDate.Month", "PublicationDate.Day", null, null, null)]
DateTime PublicationDate)

I get an exception whenever the user leaves one of the three day, month and year fields empty. Unfortunately, the BindModel method is called before I can check for empty fields in my controller, because it is triggered by the Attribute on the PublicationDate parameter. Did I do something wrong?

I did find a (temporary?) solution by adjusting the following fragment (did the same for the "time part only"):

if (GetA<int>(bindingContext, this.Year) != null &&
GetA<int>(bindingContext, this.Month) != null &&
GetA<int>(bindingContext, this.Day) != null)
{
dateAttempt = new DateTime(
GetA<int>(bindingContext, this.Year).Value,
GetA<int>(bindingContext, this.Month).Value,
GetA<int>(bindingContext, this.Day).Value,
DateTime.MinValue.Hour, DateTime.MinValue.Minute, DateTime.MinValue.Second);
}

and by adding the following else if statement at the bottom:

if (dateAttempt != null && timeAttempt != null)
{
return new DateTime(dateAttempt.Value.Year,
dateAttempt.Value.Month,
dateAttempt.Value.Day,
timeAttempt.Value.Hour,
timeAttempt.Value.Minute,
timeAttempt.Value.Second);
}
else if (dateAttempt == null && timeAttempt == null)
{
return DateTime.MinValue;
}
//Only got one half? Return as much as we have!
return dateAttempt ?? timeAttempt;
}

I can then add a line of code to my controller that checks if the PublicationDate is not DateTime.MinValue. If it is, I call ModelState.AddError. Would you say that this is a valid solution?

Regards,
Inge
Inge
Thursday, March 19, 2009 12:28:40 PM UTC
I use this with just the Date part. If I enter something that is not convertible to a DateTime, i got an asp.net exception.
The fact is that you didn't catch the exception when calling ConvertTo.

I have modify the GetA<T> function to be :

private Nullable<T> GetA<T>(ModelBindingContext bindingContext, string key) where T : struct
{
if (String.IsNullOrEmpty(key)) return null;
ValueProviderResult valueResult;
//Try it with the prefix...
string modelName = bindingContext.ModelName + "." + key;
bindingContext.ValueProvider.TryGetValue(modelName, out valueResult);
//Didn't work? Try without the prefix if needed...
if (valueResult == null && bindingContext.FallbackToEmptyPrefix == true)
{
modelName = key;
bindingContext.ValueProvider.TryGetValue(key, out valueResult);
}
if (valueResult == null)
{
return null;
}
// Add the value to the model state, without this line, a null reference exception is thrown
// when redisplaying the form with a not convertible value
bindingContext.ModelState.SetModelValue(modelName, valueResult);
try
{
return (Nullable<T>)valueResult.ConvertTo(typeof(T));
}
catch(Exception ex)
{
bindingContext.ModelState.AddModelError(modelName, ex);
return null;
}
}


my 2 cents
Wednesday, April 15, 2009 5:23:43 PM UTC
Here's my very short story... which I believe is by far one of the most common scenario on the web...

The domain model has an Order with ShipDate property. (I'll leave aside the fact that ShipDate is a DateTime since for some odd reason .Net doesn't have a simpler Date type...)

The designers and HTML coders made an order form where the ShipDate is made of 3 drop downs: day/month/year.

Are you suggesting I use a similar model binder to instantiate the Order ShipDate property from the 3 drop downs?

I challenge anyone at Microsoft that has anything remotely close to do with MVC to make a simple HTML form and domain object as I describe above and give it an EQUALLY SIMPLE IMPLEMENTATION using the provided MVC mechanics.

You will all FAIL, and that is *extremely* upsetting.
Alexandre Grenier
Wednesday, April 15, 2009 6:42:32 PM UTC
That's kind of negative. ;) You could just make the method signature:

public void CreateOrder(Order order, int month, int day, int year)

then just make a date time, new DateTime(month, day, year).

I just made the model binder to hide a lot of *generic* complexity. The *specific* problem is easy to solve.

Hope this helps.
Thursday, April 16, 2009 5:08:28 PM UTC
I just like teasing MS bloggers... Ok... now add validation :D
Alexandre Grenier
Comments are closed.

Disclaimer: The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way.