Saturday, July 16, 2011

Entity Framework concurrency checking on ASP.NET MVC (UpdateModel is the only sane approach when you want to use concurrency checking on EF)

UPDATE August 2, 2011

Perhaps I'm too harsh on Entity Framework, we can simplify concurrency checking if we used Timestamp attribute. ConcurrencyCheck doesn't work well on detached(e.g. web) scenario, work-arounds is needed. On the updated code, we change ConcurrencyCheck attribute to Timestamp attribute

Though for complex object persisting (e.g. many-to-many. http://www.ienablemuch.com/2011/07/using-checkbox-list-on-aspnet-mvc-with_16.html), even we used Timestamp attribute, we still need OriginalValue work-around. Directly persisting a detached entity which has children entities would result to duplicate children entities, as EF has no knowledge that the old children entities should be discarded; so for this type of data persistence we need to to re-simulate attached scenario, that means we directly work on entities that come from EF, and that means we need to inform EF the entity's original rowversion value if we want EF to handle concurrent updates well. To prevent duplicate children entities on an entity, we need to clear the attached entity's children; on our checkbox list demo, that is movie.Genres.Clear(). Don't worry, clearing the children entities is efficient, it doesn't actually delete the children rows on database, try to check the Sql Server profiler :-)

public class Song
{
    [Key]
    public int SongId { get; set; }
    public string SongName { get; set; }
    public string AlbumName { get; set; }

    [Timestamp]
    public virtual byte[] Version { get; set; }
}


Here's the updated Save method:

[HttpPost]
public ActionResult Save(Song song)
{
    SaveCommon(song);

    return View("Input", song); // song is the model
}



private void SaveCommon(Song song)
{
    using (var db = new TheMusicContext())
    {
        try
        {
            if (song.SongId == 0)
                db.Songs.Add(song);                                                
            else
                db.Entry(song).State = System.Data.EntityState.Modified;

            db.SaveChanges();

            ModelState.Remove("SongId"); // to force ASP.NET MVC to get the SongId value from the model, not from ModelState
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();


            var dbValues = entry.GetDatabaseValues();

            // already deleted
            if ( dbValues == null)
            {
                ModelState.AddModelError("", "This record you are attempting to save is already deleted by other user");
                return;
            }

            
            Song songDbValues = (Song) dbValues.ToObject();
            Song userValues = (Song) entry.Entity;



            if (songDbValues.SongName != userValues.SongName)
                ModelState.AddModelError("SongName", "Current value made by other user: " + songDbValues.SongName);

            if (songDbValues.AlbumName != userValues.AlbumName)
                ModelState.AddModelError("AlbumName", "Current value made by other user: " + songDbValues.AlbumName);

            ModelState.AddModelError("", "The record you attempted to edit was already modified by another user since you last loaded it. Open the latest changes on this record");
        }
        catch (Exception ex)
        {
            ModelState.AddModelError("", ex.Message + "\n" + ex.StackTrace + ex.InnerException.Message);
        }
        
      
    }

               
}//SaveCommon

Here's the updated Delete method:

public ActionResult Delete(int id, byte[] Version)
{
    using (var db = new TheMusicContext())
    {

        var stub = new Song { SongId = id, Version = Version };
        if (Request.HttpMethod == "POST")
        {
            try
            {                        
                db.Entry(stub).State = System.Data.EntityState.Deleted;
                db.SaveChanges();
                return RedirectToAction("Index");
            }
            catch (DbUpdateConcurrencyException ex)
            {
                var entry = ex.Entries.Single();
                var dbValues = entry.GetDatabaseValues();


                // already deleted
                if (dbValues == null)
                    return RedirectToAction("Index");
                                 
                ModelState.AddModelError("", "The record you are attempting to delete was modified by other user first. Do you still want to delete this record?");
                Song songDbValues = (Song)dbValues.ToObject();       

                return View(songDbValues);
            }                    
        }
        else
        {
            var songToDelete = db.Songs.Find(id);
            return View(songToDelete);
        }
    }
}


Get the updated code here: http://code.google.com/p/ef-concurrency-on-aspnet-mvc-demo/downloads/list




**STALE post. read the recommended procedure above**

Entity Framework concurrency handling doesn't work out-of-the-box if you are using it in disconnected scenario(as typified by web apps). Contrast to this(connected): http://www.ienablemuch.com/2011/05/entity-frameworks-concurrency-handling.html

When you submitted(POST) a form, you must simulate your entity like it was just first loaded from the GET method(e.g. http://Song/Edit/1), essentially you need to simulate connected mode so EF can compare if the record's rowversion have changed since it was first loaded. On the code below, that simulation is in line #22 and 23


ASP.NET's MVC model binding doesn't seem to be of much use on Entity Framework. Entity Framework cannot work with the posted model directly. Contrast that with NHibernate http://www.ienablemuch.com/2011/07/nhibernate-concurrency-checking-on.html

[HttpPost]
public ActionResult Save(Song song)
{
    using (var db = new TheMusicContext())
    {
        try
        {
            bool isNew = song.SongId == 0;

            var songToDb = !isNew ? db.Songs.Find(song.SongId) : song;

            if (songToDb == null) throw new Exception("This record you are attempting to save is already deleted by other user");


            if (isNew)
                db.Songs.Add(songToDb); // Primary key is automatically populated
            else
            {
                // To facilitate concurrency checks, do these two lines.
                // When EF perform an update, it compares the DB value from OriginalValue.
                // Leaky abstractions rear its ugly head when you want to do concurrency checks on EF ;-)
                db.Entry(songToDb).Property("Version").OriginalValue = song.Version;
                db.Entry(songToDb).State = System.Data.EntityState.Unchanged;

                // There's too much implicitness here, UpdateModel get values from Request.Form/Request.QueryString, Model Binding (variable song) isn't of much use here.
                // But UpdateModel is the only sane approach when you want to use concurrency checking on EF,
                // as it has array parameter, to which you can selectively pass properties via Linq-to-objects
                // Automapper is another approach? http://automapper.codeplex.com/
                // Is Model Binding useless on EF?
                // One thing for sure, Model Binding is not useless on NHibernate :-)
                // http://www.ienablemuch.com/2011/07/nhibernate-concurrency-checking-on.html
                UpdateModel(songToDb, 
                      typeof(Song).GetProperties()
                      .Where(x => x.Name != "Version")
                      .Select(x => x.Name).ToArray());
            }


            db.SaveChanges();

            db.Entry(songToDb).Reload(); // Fetch the Version column from the database
            song.Version = songToDb.Version;


            ModelState.Remove("SongId"); // to force ASP.NET to get the SongId value from the model, not from ModelState

            // no need to issue ModelState.Remove on Version property; with byte array, ASP.NET MVC always get its value from the model, not from ModelState
            // ModelState.Remove("Version") 
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();
            Song dbValues = (Song)entry.GetDatabaseValues().ToObject();
            Song userValues = (Song)entry.Entity;



            if (dbValues.SongName != userValues.SongName)
                ModelState.AddModelError("SongName", "Current value made by other user: " + dbValues.SongName);

            if (dbValues.AlbumName != userValues.AlbumName)
                ModelState.AddModelError("AlbumName", "Current value made by other user: " + dbValues.AlbumName);

            ModelState.AddModelError("", "The record you attempted to edit was already modified by another user since you last loaded it. Open the latest changes on this record");
        }
        catch (Exception ex)
        {
            ModelState.AddModelError("", ex.Message);
        }
        

        return View("Input", song); // song is the model

    }       
}


Model:
public class Song
{
    [Key]
    public int SongId { get; set; }
    public string SongName { get; set; }
    public string AlbumName { get; set; }

    [ConcurrencyCheck]
    public virtual byte[] Version { get; set; }
}

The View:
@model EfConcurrencyOnAspNetMvc.Models.Song

@{
    ViewBag.Title = "Input";
}

<h2>Input</h2>

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>

@{ string controllerName = (string) ViewContext.RouteData.Values["Controller"]; }

@using (Html.BeginForm("Save", controllerName)) {
    @Html.ValidationSummary(true)

    @Html.HiddenFor(x => x.SongId)
    @Html.HiddenFor(x => x.Version)
    <fieldset>
        <legend>Song</legend>

        <div class="editor-label">
            @Html.LabelFor(model => model.SongName)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.SongName)
            @Html.ValidationMessageFor(model => model.SongName)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.AlbumName)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.AlbumName)
            @Html.ValidationMessageFor(model => model.AlbumName)
        </div>

        <p>
            <input type="submit" value="Save" />
        </p>
    </fieldset>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

The DDL:
create table Song
(
SongId int identity(1,1) not null primary key,
SongName varchar(50) not null,
AlbumName varchar(50) not null,
Version rowversion not null
);

Complete demo code: http://code.google.com/p/ef-concurrency-on-aspnet-mvc-demo/downloads/list


Sample Output:

No comments:

Post a Comment