Linq to Sql and ASP.NET MVC - AutoCommit and the RoutePreParser

This is the second in a series of posts on using ASP.NET MVC with Linq to Sql:

Code for this series is available here.

In my previoust post, I demonstrated how you could scope a Linq to Sql DataContext to a single HTTP Request by using StructureMap to manage the lifetime of the DataContext instance. This often works well, but has a couple of gotchas. Let's take a look at an example:

public class PostController : Controller {
	private readonly BlogDataContext context;
 
	public PostController(BlogDataContext context) {
		this.context = context;
	}
 
	public ActionResult Create() {
		return View();
	}
 
	[AcceptVerbs(HttpVerbs.Post), AutoCommit]
	public ActionResult Create(Post post) {
		context.Posts.InsertOnSubmit(post);
		return RedirectToAction("Show", new{ id = post.Id });
	}
 
	public ActionResult Show(int id) {
		var post = context.Posts.SingleOrDefault(x => x.Id == id);
		if (post == null) {
			throw new HttpException(404, "The post could not be found.");
		}
 
		return View(post);
	}
}

In this example, we have a Post controller for our fictional Blog database. The Create action renders a view where the user can create a new Post and then sends an HTTP POST to the other overload to the Create action (decorated with AcceptVerbs(HttpVerbs.Post)).

This action is decorated with the AutoCommit attribute from my previous post so that SubmitChanges will automatically be called on our DataContext. We then redirect to the "Show" action passing the Id of the newly created post in the route data.

The Show action loads the Post from the database with the corresponding Id and displays it to the user. If a post with the specified Id could not be found, it will throw a 404 exception.

There is a bug here that may not be immediately obvious. Assume that the Post's Id property is generated by an auto-incrementing Identity field in the database. If you were to run this application and create a new Post, the Show action will always throw 404 even though the Post has been successfully created.

This happens because of when SubmitChanges is called. Our AutoCommit filter is invoked after the action has finished executing this means that at the time we call RedirectToAction("Show", new{ id = post.Id }) the new post has not yet been saved, so its Id will be 0.

Linq to Sql will not update the Id property until after SubmitChanges is called, which is too late in the process. The user will end up being redirected to Post/Show/0 instead of using the Id of the newly created post.

The simple workaround is to call SubmitChanges directly inside the action:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create(Post post) {
	context.Posts.InsertOnSubmit(post);
 
	if (ModelState.IsValid) {
		using (var transaction = new TransactionScope()) {
			context.SubmitChanges();
			transaction.Complete();
		}
	}
 
	return RedirectToAction("Show", new { id = post.Id });
}

...but this then makes our AutoCommit attribute useless and also makes the controller action significantly more complex.

However, there is another approach we could use which still allows us to use the AutoCommit attribute. To understand this, we first need to look at the order in which things are happening when our action is invoked:

  1. The IoC container creates our DataContext
  2. The controller is instantiated and the DataContext is passed to its constructor
  3. The Create action is invoked with a Post instance created by MVC's DefaultModelBinder
  4. The post is attached to the DataContext by calling InsertOnSubmit
  5. A RedirectToRouteResult is created by calling "RedirectToAction" with a dictionary of route values
  6. The AutoCommit attribute calls SubmitChanges to our DataContext
  7. The Post is written to the database
  8. ExecuteResult on RedirectToRouteResult is invoked
  9. The Redirect URL is generated by the RouteCollection
  10. The user's browser is redirected to this URL

Instead of passing the Post Id (which will be 0) to RedirectToAction, we could pass the entire post instance. After the post has been saved we can then *replace* the post instance in the RouteValueDictionary with the post's Id before the URL is generated. This can be done by intercepting the RouteValueDictionary just before the URL is generated.

Introducing the RoutePreParser

The first thing we need is a way to identify how an object (in this case, our Post) should be converted to a route value. To do this, we can create an interface, IUrlRoutable:

public interface IUrlRoutable {
	object GetRouteParameter();
}

...and we can implement this interface in our Post class:

public partial class Post : IUrlRoutable {
	public object GetRouteParameter() {
		return Id;
	}
}

Next we can create a "fake" route. This route never generates a URL or handles a request - it is merely used to intercept the RouteValueDictionary before a URL is generated:

public class RoutePreParser : RouteBase {
	public override RouteData GetRouteData(HttpContextBase httpContext) {
		return null;
	}
 
	public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values) {
		var query = from pair in values
				where pair.Value != null
				let routable = pair.Value as IUrlRoutable
				where routable != null
				select new { pair.Key, Value = routable.GetRouteParameter() };
 
		foreach (var pair in query.ToList()) {
			values[pair.Key] = pair.Value;
		}
 
		return null;
	}
}

Here we loop through each key-value pair in the RouteValueDictionary. If the value implements IUrlRoutable then we call GetRouteParameter on that object and replace the original value in the RouteValueDictionary with the result of this method.

Next, we have to add the fake route to the RouteCollection in Application_Start. Note that this must be the first route added so that it gets a chance to intercept the RouteValueDictionary.

public static void RegisterRoutes(RouteCollection routes) {
	//Add our fake route first
	routes.Add(new RoutePreParser());
 
	routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
 
	routes.MapRoute(
		"Default", // Route name
		"{controller}/{action}/{id}", // URL with parameters
		new { controller = "Home", action = "Index", id = "" } // Parameter defaults
	);
}

Now, in our controller action we change the Create action to store the Post instance in the route values:

[AcceptVerbs(HttpVerbs.Post), AutoCommit]
public ActionResult Create(Post post) {
	context.Posts.InsertOnSubmit(post);
	return RedirectToAction("Show", new{ id = post });
}

...and everything now works as expected.

To recap, this is the new chain of events:

  1. The IoC container creates our DataContext
  2. The controller is instantiated and the DataContext is passed to its constructor
  3. The Create action is invoked with a Post instance created by MVC's DefaultModelBinder
  4. The post is attached to the DataContext by calling InsertOnSubmit
  5. A RedirectToRouteResult is created by calling "RedirectToAction" with a dictionary of route values. (The "Id" parameter is our new Post instance.)
  6. The AutoCommit attribute calls SubmitChanges to our DataContext
  7. The Post is written to the database
  8. The Post instance stored in the RouteValueDictionary automatically has its Id property updated
  9. ExecuteResult on RedirectToRouteResult is invoked
  10. Our RoutePreParser inspects the RouteValueDictionary.
  11. GetRouteParameter on Post is invoked, returning the now-populated Post Id
  12. The RoutePreParser removes the Post instance from the RouteValueDictionary
  13. The RoutePreParser inserts the Post Id as the "id" in the RouteValueDictionary
  14. The (now correct) redirect URL is generated by the RouteCollection
  15. The user's browser is redirected to this URL
Written on February 1, 2010