Saturday, July 16, 2011

Using checkbox list on ASP.NET MVC with Entity Framework 4.1 (telling the users about the third table is a leaky abstraction)

UPDATE August 2, 2011
The code is now efficient, movie.Genres.Add can just receive an stub object


[HttpPost]
public ActionResult Save(MovieInputViewModel input)
{
    using (var db = new TheMovieContext())
    {
        try
        {
            bool isNew = input.TheMovie.MovieId == 0;

            input.SelectedGenres = input.SelectedGenres ?? new List<int>();                    
            input.GenreSelections = db.Genres.AsNoTracking().OrderBy(x => x.GenreName).ToList();


            var movie = !isNew ? db.Movies.Find(input.TheMovie.MovieId) : input.TheMovie;                    

            if (isNew)
                db.Movies.Add(movie);
            else
            {
                db.Entry(movie).Property(x => x.Version).OriginalValue = input.TheMovie.Version;
                                        
                db.Entry(movie).State = EntityState.Modified;
            }


            movie.Genres = movie.Genres ?? new List<Genre>();
            movie.Genres.Clear();
            foreach (int g in input.SelectedGenres)
                movie.Genres.Add(db.LoadStub<Genre>(g));
            
            UpdateModel(movie, "TheMovie", includeProperties: null, excludeProperties: new string[] { "Version" });                   

            db.SaveChanges();

            
            input.TheMovie.Version = movie.Version;


            ModelState.Remove("TheMovie.MovieId");

            // No need to remove TheMovie.Version, ASP.NET MVC is not preserving the ModelState of variables with byte array type.
            // Hence, with byte array, the HiddenFor will always gets its value from the model, not from the ModelState
            // ModelState.Remove("TheMovie.Version"); 


            input.MessageToUser = input.MessageToUser + " " + string.Format("Saved. {0}", isNew ? "ID is " + input.TheMovie.MovieId : "");

        }
        catch (DbUpdateConcurrencyException)
        {
            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");
        }
    }

    return View("Input", input);
}

We changed the Movie rowversion's attribute to Timestamp attribute(from ConcurrencyCheck attribute) so we will not need any work-around anymore(always setting one property to force-dirty the record)

public class Movie
{
    [Key]
    public virtual int MovieId { get; set; }
    
    [   Required, Display(Name="Title")
    ]   public virtual string MovieName { get; set; }
    
    [   Required, Display(Name="Description")
    ]   public virtual string MovieDescription { get; set; }
    
    [   Required, Display(Name="Year Released"), Range(1900,9999)
    ]   public virtual int? YearReleased { get; set; }
    
    [Timestamp]
    public virtual byte[] Version { get; set; }

    
    public virtual IList<Genre> Genres { get; set; }              
}

Here's the LoadStub extension method. If we could know how to get the primary key name of an entity, we will remove the primayKeyName property in code. For now, the convention-over configuration(using Entity name + ID as the default primary key) could prevent explicitly passing the primary key name.

Get the LoadStub code at http://www.ienablemuch.com/2011/08/entity-frameworks-nhibernate_02.html




Old post

Using checkboxes is a many-to-many design. In this post, I'll show you how to make the process of inserting to a third table a fairly automatic process with Entity Framework.

Most of the supporting logic is similar with http://www.ienablemuch.com/2011/07/using-checkbox-list-on-aspnet-mvc-with.html(NHibernate version)

The only difference is in the way the model is being saved.

[HttpPost]
public ActionResult Save(MovieInputViewModel input)
{
    using (var db = new TheMovieContext())
    {
        try
        {
            bool isNew = input.TheMovie.MovieId == 0;

            input.SelectedGenres = input.SelectedGenres ?? new List<int>();                    
            input.GenreSelections = db.Genres.AsNoTracking().OrderBy(x => x.GenreName).ToList();


            var movie = !isNew ? db.Movies.Find(input.TheMovie.MovieId) : input.TheMovie;

            if (isNew)
                db.Movies.Add(movie);
            else
            {
                db.Entry(movie).Property("Version").OriginalValue = input.TheMovie.Version;
                db.Entry(movie).State = System.Data.EntityState.Unchanged;


                // dirty this all the time even this is not changed by the user, 
                // so we have a simplified mechanism for concurrent update 
                // when the associated Genre(s) is modified
                db.Entry(movie).Property("MovieName").IsModified = true;
            }


            movie.Genres = movie.Genres ?? new List<Genre>();
            movie.Genres.Clear();
            foreach (int g in input.SelectedGenres)
            {
                // What is Entity Framework 4.1 analogous to NHibernate's session.Load<Genre>(g) ? My google-fu is failing me
                // db.Genres.Find(g) is not efficient
                movie.Genres.Add(db.Genres.Find(g));
                // Solution for db.Genres.Find inefficiency:
                // http://www.ienablemuch.com/2011/07/entity-framework-asnotracking-and.html
            }

            // http://www.joe-stevens.com/2010/02/17/asp-net-mvc-using-controller-updatemodel-when-using-a-viewmodel/
            // null will get all properties under TheMovie object, the Version is excluded                 
            UpdateModel(movie, "TheMovie", includeProperties: null, excludeProperties: new string[] { "Version" });



            db.SaveChanges();

            db.Entry(movie).Reload();
            input.TheMovie.Version = movie.Version;


            ModelState.Remove("TheMovie.MovieId");

            // No need to remove TheMovie.Version, ASP.NET MVC is not preserving the ModelState of variables with byte array type.
            // Hence, with byte array, the HiddenFor will always gets its value from the model, not from the ModelState
            // ModelState.Remove("TheMovie.Version"); 



            input.MessageToUser = string.Format("Saved. {0}", isNew ? "ID is " + input.TheMovie.MovieId : "");

        }
        catch (DbUpdateConcurrencyException)
        {
            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");
        }
    }

    return View("Input", input);
}

The models. Note, the following models has a circular reference, this sort of thing is not possible on a platform like RDBMS. That's why platforms that can describe many-to-many scenario in an elegant manner and can be conveyed intuitively to users, is very appealing to developers who want to model the problem domain in terms of user's lingo, and fortunately one of those platforms is your favorite language(mine happens to be C#), C# don't have problems with circular references . Users don't know the fuss what's a third table is all about. Don't tell them about a third table, that will be a leaky abstraction. It's for you(developer) to infer if a third table is warranted; an example, a Genre happen to have many Movies, and a Movie can have many Genres too.

public class Genre
{
 [Key]
 public virtual int GenreId { get; set; }
 public virtual string GenreName { get; set; }

 public virtual IList<Movie> Movies { get; set; }
}


public class Movie
{
 [Key]
 public virtual int MovieId { get; set; }
 
 [   Required, Display(Name="Title")
 ]   public virtual string MovieName { get; set; }
 
 [   Required, Display(Name="Description")
 ]   public virtual string MovieDescription { get; set; }
 
 [   Required, Display(Name="Year Released"), Range(1900,9999)
 ]   public virtual int? YearReleased { get; set; }
 
 [ConcurrencyCheck]
 public virtual byte[] Version { get; set; }

 
 public virtual IList<Genre> Genres { get; set; }               
}


ORM mapping. We have three tables, namely Movie, Genre, and MovieAssocGenre.
public class TheMovieContext : DbContext
{
 public DbSet<Movie> Movies { get; set; }
 public DbSet<Genre> Genres { get; set; }


 protected override void OnModelCreating(DbModelBuilder modelBuilder)
 {           
  modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();

  modelBuilder.Entity<Movie>().HasMany(x => x.Genres).WithMany(x => x.Movies).Map(x =>
   {
    x.ToTable("MovieAssocGenre");
    x.MapLeftKey("MovieId");
    x.MapRightKey("GenreId");
   });

  modelBuilder.Entity<Genre>().HasMany(x => x.Movies).WithMany(x => x.Genres).Map(x =>
  {
   x.ToTable("MovieAssocGenre");
   x.MapLeftKey("GenreId");
   x.MapRightKey("MovieId");
  });
  
  
  base.OnModelCreating(modelBuilder);
 }


}


DDL:
create table Movie
(
MovieId int identity(1,1) not null primary key,
MovieName varchar(100) not null unique,
MovieDescription varchar(100) not null,
YearReleased int not null,
Version rowversion not null
);

create table Genre
(
GenreId int identity(1,1) not null primary key,
GenreName varchar(100) not null unique
);
 
create table MovieAssocGenre
(
MovieAssocGenreId int identity(1,1) not null primary key,
MovieId int not null references Movie(MovieId),
GenreId int not null references Genre(GenreId),
constraint uk_MovieAssocGenre unique(MovieId, GenreId)
);
  
insert into Genre(GenreName) values('Action'),('Comedy'),('Documentary'),('Romance'),('Sci-Fi'),('Thriller');


Get the complete code from http://code.google.com/p/ef-aspnet-mvc-checkbox-list-demo/downloads/list

Solution found on db.Genres.Find always hitting the database: http://www.ienablemuch.com/2011/07/entity-framework-asnotracking-and.html

Sample output

13 comments:

  1. assuming your movie doesn't have a FK for genre, you can just create a local instance of the genre and attach it to movie with an IMPORTANT caveat. By default, EF will think that's a new genre and want to insert it. You need to set it's state to Unchanged...
    var genre=new Genre{id=g};
    context.Entry(genre).State=EntityState.Unchanged;
    movie.Genres.Add(genre);

    Doing this off the top of my head so please test!!

    ReplyDelete
  2. That's what I first attempted. Unfortunatelly, does not work too

    ReplyDelete
  3. wow...just did a test. I'm shocked that it's still trying to update the entityref (your Genre... different domain in my example). Will ask team about this.

    ReplyDelete
  4. ahh wait...the classes I was using for this test had a FK property. I don't like what I saw there but when Movie has a Genre property, but *NO* genreId foreign key, this works exactly as expected. My test: query for a movie, create an on the fly genre with the known ID (set only the ID property), use context.Entry(genre).State=Unchanged, set movie.Genre to my new Genre, then save changes.

    So...do you have a FK property for GenreId in movie? (If you do why not just set that to "g"?)

    ReplyDelete
  5. Movie has no GenreId property(FK), Movie is many-to-many with Genre, nothing to set. In my DDL, their relationship is defined in third table, MovieAssocGenre table. Most of the article I read on EF(even NH) regarding many-to-many, an app don't deal directly with third table, I don't know if it's advisable to make a class for the third table, seems it will break the proper domain modeling, a leaky abstraction.

    ReplyDelete
  6. Would be very handy to have some SQL script to create the DB and populate it with some sample data..

    ReplyDelete
  7. using EF, How do I implement validation for the checkbox list. At least one checkbox has to be selected.

    ReplyDelete
    Replies
    1. To do that, the model must implement IValidatableObject(see here for example implementation: http://www.ienablemuch.com/2011/07/ivalidatableobject-client-side.html), e.g.

      public class Movie : IValidatableObject
      {
      .
      .
      .

      public virtual IEnumerable Validate(ValidationContext validationContext)
      {
      if (this.Genres.Count == 0) {
      yield return new ValidationResult("At least one genre must be selected");
      }
      }
      }

      IValidatableObject not only is recognized by ASP.NET MVC, it is recognized by Entity Framework too. i.e. even you are not using ASP.NET MVC, Entity Framework will look at your IValidatableObject's Validate implementation, and if you have returned ValidationResult, Entity Framework will throw a DbEntityValidationException exception.

      Another good example of Entity Framework validation: http://www.remondo.net/entity-framework-validation-ivalidatableobject-example/


      Delete







  8. it show my selected hazards, and i want to update them, could you help me please

    ReplyDelete
  9. what is movieinputviewmodel
    please send me view and controllerpage code

    ReplyDelete
    Replies
    1. https://code.google.com/p/ef-aspnet-mvc-checkbox-list-demo/source/browse/trunk/AspNetMvcCheckboxListEf/AspNetMvcCheckboxListEf/ViewsModels/MovieInputViewModel.cs

      Delete