Sunday, July 10, 2011

Using checkbox list on ASP.NET MVC with NHibernate

The core logic of checkbox list:

@foreach (var g in Model.GenreSelections)
{                                
    <input type="checkbox" name="SelectedGenres" value="@g.GenreId" @(Model.SelectedGenres.Contains(g.GenreId) ? "checked" : "") /> @g.GenreName
}

Add the models

Movie model

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel.DataAnnotations;


namespace AspNetMvcCheckboxList.Models
{
    public class Movie
    {
        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; }
        
        public virtual byte[] Version { get; set; }

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


Genre model
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace AspNetMvcCheckboxList.Models
{
    public class Genre
    {
        public virtual int GenreId { get; set; }
        public virtual string GenreName { get; set; }

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


Then add a ViewsModels folder on your project, and add the following view model, note line #15 and #17:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using AspNetMvcCheckboxList.Models;

namespace AspNetMvcCheckboxList.ViewsModels
{
    public class MovieInputViewModel
    {
        public string MessageToUser { get; set; }

        public Movie TheMovie { get; set; }
        
        public List<Genre> GenreSelections{ get; set; }

        public List<int> SelectedGenres { get; set; }
    }
}

Add the Controller

Add the Movie controller, and add this Input action, note line #10:
public ViewResult Input(int id = 0)
{
    using (var s = Mapper.GetSessionFactory().OpenSession())
    {
        var movie = id != 0 ? s.Get<Movie>(id) : new Movie { Genres = new List<Genre>() };
        return View(new MovieInputViewModel
        {
            TheMovie = movie,
            GenreSelections = s.Query<Genre>().OrderBy(x => x.GenreName).ToList(),
            SelectedGenres = movie.Genres.Select(x => x.GenreId).ToList()
        });
    }
}

and add the Save action below. Note line #11, if the user don't check anything, the SelectedGenres won't be populated by ASP.NET MVC, and will be left as null. Note line #16, that pushes the selected genres by a user to Movie's Genre collection, use Load, don't use Get. When saving the third table, we are not interested on getting the genre record from database, only its ID(which is already known by the application, database round-trip is not necessary and will not happen), Load prevents eager fetching of object from database. Load doesn't hit the database, it just prepare the object's proxy by assigning it an ID; which when it's time for the program to access the object's properties other than the object ID, that's the time the object will hit the database(to fetch the rest of the properties of the object) by means of its ID(primary key).

[HttpPost]
public ActionResult Save(MovieInputViewModel input)
{
    using (var s = Mapper.GetSessionFactory().OpenSession())
    using (var tx = s.BeginTransaction())
    {
        try
        {
            bool isNew = input.TheMovie.MovieId == 0;

            input.SelectedGenres = input.SelectedGenres ?? new List<int>();
            input.GenreSelections = s.Query<Genre>().OrderBy(x => x.GenreName).ToList();

            input.TheMovie.Genres = new List<Genre>();
            foreach (int g in input.SelectedGenres)
                input.TheMovie.Genres.Add(s.Load<Genre>(g));


            s.SaveOrUpdate(input.TheMovie); // Primary key(MovieId) is automatically set with SaveOrUpdate, and the row version (Version) field too.


            tx.Commit();


            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 (StaleObjectStateException)
        {
            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);
}

Note on Save code last line, we just re-use the Input's view.


Add the supporting View

This is our Input's view, note line #56, that's the checkbox list mechanism; we also take into account the concurrent update of same movie, line #22:

@model AspNetMvcCheckboxList.ViewsModels.MovieInputViewModel

@{
    ViewBag.Title = "Movie";
}


<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)
    <fieldset>
        <legend>Movie</legend>

        
        @Html.HiddenFor(model => model.TheMovie.MovieId)
        @Html.HiddenFor(model => model.TheMovie.Version)

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


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


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


        <fieldset>
            <legend>Genres</legend>
            
            @foreach (var g in Model.GenreSelections)
            {                                
                <input type="checkbox" name="SelectedGenres" value="@g.GenreId" @(Model.SelectedGenres.Contains(g.GenreId) ? "checked" : "") /> @g.GenreName                
            }
        </fieldset>

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

            
            <a href="@Url.Content(string.Format(@"~/{0}/Input", controllerName))">New</a>

            
        </p>
    </fieldset>
    
    <p>@Model.MessageToUser</p>
}

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


Add the mapper

Uses automapping via Fluent NHibernate. The third table(MovieAssocGenre) is in line 38,39.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Reflection;

using NHibernate;
using NHibernate.Dialect;

using FluentNHibernate.Cfg;
using FluentNHibernate.Cfg.Db;
using FluentNHibernate.Automapping;
using FluentNHibernate.Conventions;
using FluentNHibernate.Conventions.Instances;
using FluentNHibernate.Conventions.Helpers;

using AspNetMvcCheckboxList.Models;


namespace AspNetMvcCheckboxList
{
    public static class Mapper
    {
        static ISessionFactory _sf = null;
        public static ISessionFactory GetSessionFactory()
        {
            if (_sf != null) return _sf;

            var fc = Fluently.Configure()
                    .Database(MsSqlConfiguration.MsSql2008.ConnectionString(@"Data Source=localhost;Initial Catalog=TestNhCheckboxList;User id=sa;Password=P@$$w0rd"))
                    .Mappings
                    (m =>
                            m.AutoMappings.Add
                            (
                                AutoMap.AssemblyOf<MvcApplication>(new CustomConfiguration())
                                   .Conventions.Add(ForeignKey.EndsWith("Id"))
                                   .Conventions.Add<RowversionConvention>()  
                                   .Override<Movie>(x => x.HasManyToMany(y => y.Genres).Table("MovieAssocGenre") /* .ParentKeyColumn("MovieId").ChildKeyColumn("GenreId") // optional, the conventions overrides take care of these :-) */ )
                                   .Override<Genre>(x => x.HasManyToMany(y => y.Movies).Table("MovieAssocGenre") /* .ParentKeyColumn("GenreId").ChildKeyColumn("MovieId") // optional, the conventions overrides take care of these :-) */ )
                            )
                    // .ExportTo(@"C:\_Misc\NH")                
                    );


            // Console.WriteLine( "{0}", string.Join( ";\n", fc.BuildConfiguration().GenerateSchemaCreationScript(new MsSql2008Dialect() ) ) );
            // Console.ReadLine();

            _sf = fc.BuildSessionFactory();
            return _sf;
        }


        class CustomConfiguration : DefaultAutomappingConfiguration
        {
            IList<Type> _objectsToMap = new List<Type>()
            {
                // whitelisted objects to map
                typeof(Movie), typeof(Genre)
            };
            public override bool ShouldMap(Type type) { return _objectsToMap.Any(x => x == type); }
            public override bool IsId(FluentNHibernate.Member member) { return member.Name == member.DeclaringType.Name + "Id"; }            
        }


        class RowversionConvention : IVersionConvention
        {
            public void Apply(IVersionInstance instance) { instance.Generated.Always(); }
        }


    }
}

And this is the physical implementation. The R in ORM :-)

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 code here: http://code.google.com/p/aspnet-mvc-checkbox-list-demo/downloads/list

To contrast NHibernate approach to Entity Framework, visit http://www.ienablemuch.com/2011/07/using-checkbox-list-on-aspnet-mvc-with_16.html

Sample output:

1 comment:

  1. Thanks, by checking your source i found the bug in my implementation which is very cool now :-)

    ReplyDelete