viernes, 19 de junio de 2015

JqGrid en Aplicaciones MVC con Entity Framework

En muchas aplicaciones web es necesario usar grillas que muestren la información contenida en una base de datos y permitan ordenar, filtrar y paginar los datos. Dependiendo de las herramientas que se usen, esto puede requerir más o menos trabajo. Este artículo presenta una combinación de herramientas para facilitar el manejo de grillas JqGrid de forma sencilla y con buena performance, debido a que el filtrado, ordenamiento y paginado se hace a nivel de base de datos.

Para el ejemplo utilizaremos Entity Framework, pero se puede adaptar fácilmente a NHibernate.

El código de ejemplo está creado con Visual Studio 2013.

 

Creación del proyecto

1- Crear un nuevo proyecto MVC en Visual Studio

clip_image002

2- Seleccionar un proyecto de tipo MVC y configurar la autenticación del proyecto como “No Authentication” (para hacerlo lo más simple posible)

clip_image004

3- Agregar los siguientes paquetes Nuget, en su última versión:

· Entity Framework

clip_image005

· JqGrid

clip_image006

· MvcJqGrid

clip_image007

4- En el Nuget Package Manager, además actualizar todos los paquetes con la opción “Update All”, de manera de trabajar con las últimas versiones.

 

Grilla básica

La aplicación que vamos a crear es una aplicación MVC básica. En una aplicación real sería conveniente crear capas intermedias para las diferentes responsabilidades, pero en este caso vamos a interactuar con el repositorio de datos directamente desde el Controller para simplificar el ejemplo.

1- Creamos dos clases de modelo en la carpeta Models:

Employee

public class Employee

{

[Key]

public virtual int Id { get; set; }

public virtual string FirstName { get; set; }

public virtual string LastName { get; set; }

public virtual decimal Salary { get; set; }

public virtual Organization Organization { get; set; }

}

Organization

public class Organization

{

[Key]

public virtual int Id { get; set; }

public virtual string Name { get; set; }

public virtual string Address { get; set; }

}

2- Creamos un nuevo Controller en la carpeta controllers, con el template de Entity Framework

clip_image008

3- Seleccionamos el modelo y creamos un nuevo context

clip_image009

4- Agregamos un link al nuevo controller en Views/Shared/_Layout.cshtml

<li>@Html.ActionLink("Employees", "Index", "Employee")</li>

Si en este momento ejecutamos la aplicación y vamos a la ruta /Employee, aparece el listado de empleados

5- En este punto deberíamos poder crear algunos empleados de ejemplo

clip_image011

Si abrimos la base de datos desde Visual Studio deberíamos poder ver las tablas que creó Entity Framework.

clip_image012

6- Agregamos algunos datos de prueba en las dos tablas (al menos 6 empleados, así se puede probar la paginación):

clip_image013

clip_image014

Ahora vamos a reemplazar el listado por default que creó el proyecto de MVC por una grilla de JqGrid

7- Borramos la tabla y la cambiamos por este código usando el helper de MvcJqGrid para crear una grilla. También se podría agregar directamente la grilla por Javascript:

@(Html.Grid("employee")

.SetCaption("Employees")

.AddColumn(new Column("FirstName").SetLabel("First Name"))

.AddColumn(new Column("LastName").SetLabel("Last Name"))

.AddColumn(new Column("Salary").SetLabel("Salary").SetSearchOption(SearchOptions.Equal))

.AddColumn(new Column("Organization.Name").SetLabel("Org. Name"))

.AddColumn(new Column("Organization.Address").SetLabel("Org. Address"))

.SetUrl("/Employee/List/")

.SetAutoWidth(true)

.SetHeaderTitles(true)

.SetViewRecords(true)

.SetPager("pager")

.SetSearchToolbar(true)

.SetSearchOnEnter(false)

.SetSearchClearButton(true)

.SetSearchToggleButton(false)

.SetSortName("LastName")

.SetRowNum(5))

8- En el controller EmployeeController agregamos el método List, en donde obtenemos los empleados de la base de datos y los transformamos a un objeto JSON en el formato que espera JqGrid:

public ActionResult List()

{

var employees = db.Employees.ToList();

var jsonData = new

{

page = 1,

records = employees.Count,

rows = (

from e in employees

select new

{

id = e.Id,

cell = new Object[]

{

e.FirstName,

e.LastName,

e.Salary

}

}).ToArray()

};

return Json(jsonData, JsonRequestBehavior.AllowGet);

}

El paquete NuGet de JqGrid no agrega automáticamente bundles para Javascript y CSS, por lo cual hay que agregarlos manualmente.

9- En App_Start/BundleConfig.cs agregamos bundles para JqGrid

bundles.Add(new ScriptBundle("~/bundles/jqgrid").Include(

"~/Scripts/i18n/grid.locale-en.js",

"~/Scripts/jquery.jqGrid.min.js"));

bundles.Add(new StyleBundle("~/Content/cssJqGrid").Include(

"~/Content/jquery.jqGrid/ui.jqgrid.css",

"~/Content/themes/base/all.css"));

10- Y los agregamos en el layout, en la sección <head>en este orden (es necesario mover el bundle de JQuery agregado al crear el proyecto a la sección de <head>)

@Styles.Render("~/Content/css")

@Styles.Render("~/Content/cssJqGrid")

@Scripts.Render("~/bundles/modernizr")

@Scripts.Render("~/bundles/jquery")

@Scripts.Render("~/bundles/jqgrid")

Si ejecutamos la aplicación en este momento ya se deberían ver los datos de empleados en la grilla de JqGrid. En este punto todavía no va a funcionar el ordenamiento, filtros ni paginado.

clip_image016

Agregar acciones en la grilla

Para agregar las funcionalidades de ordenamiento, filtros y paginado tenemos que modificar el método List de EmployeeController para que tome como parámetro un objeto GridSettings y agregar unas clases de soporte.

1- Agregamos las clases PagedQuery, PagedList y JqGridExtensions. Al final del artículo se muestra el código de estas clases, que se encargan de transformar los parámetros de JqGrid en queries que Entity Framework pueda ejecutar.

2- Modificamos el método List del Controller:

public ActionResult List(GridSettings grid)

{

var query = grid.ToPagedQuery<Employee>();

var employees = query.ExecuteOn(db.Employees);

var jsonData = new

{

total = (int)Math.Ceiling((double)employees.TotalItems / employees.PageSize),

page = employees.PageNumber,

records = employees.TotalItems,

rows = (

from e in employees

select new

{

id = e.Id,

cell = new Object[]

{

e.FirstName,

e.LastName,

e.Salary

}

}).ToArray()

};

return Json(jsonData, JsonRequestBehavior.AllowGet);

}

Con estos cambios ya deberían funcionar automáticamente el ordenamiento, paginado y los filtros, todo ejecutado directamente en la base de datos. En la siguiente imagen se puede ver el filtrado por la segunda columna:

clip_image018

Entidades relacionadas

El mismo esquema funciona con entidades relacionadas, simplemente usando la notación EntidadRelacionada.Propiedad

1- Agregamos dos columnas correspondientes a la entidad Organization en la misma grilla, modificando el método List del controller:

AddColumn(new Column("Salary").SetLabel("Salary").SetSearchOption(SearchOptions.Equal))

.AddColumn(new Column("Organization.Name").SetLabel("Org. Name"))

.AddColumn(new Column("Organization.Address").SetLabel("Org. Address"))

.SetUrl("/Employee/List/")

2- Y las agregamos también en el método List del controller:

e.Salary,

e.Organization.Name,

e.Organization.Address

}

Con estos cambios alcanza para que se vean las columnas relacionadas en la grilla y funcionen los filtros y el ordenamiento a través de la relación Employee – Organization.

clip_image020

Clases auxiliares

PagedQuery

Esta clase mantiene y aplica los criterios de filtros, ordenamiento y paginado. Trabaja sobre IQueryable en forma genérica, por lo que se puede aplicar a cualquier fuente de datos. En este caso la usamos contra un repositorio de EntityFramework.

public class PagedQuery<TModel>

{

public Expression<Func<TModel, bool>> Condition { get; set; }

private Expression<Func<TModel, object>>[] SortKeySelectors { get; set; }

private int PageSize { get; set; }

private int Page { get; set; }

private bool[] SortAscending { get; set; }

private PagedQuery(Expression<Func<TModel, bool>> condition)

{

PageSize = 0;

Page = 0;

Condition = condition;

SortKeySelectors = new Expression<Func<TModel, object>>[0];

SortAscending = new bool[0];

}

public static PagedQuery<TModel> All()

{

return new PagedQuery<TModel>(null);

}

public PagedQuery<TModel> FetchPage(int page)

{

Page = page;

return this;

}

public PagedQuery<TModel> Size(int pageSize)

{

PageSize = pageSize;

return this;

}

private PagedQuery<TModel> OrderBy(Expression<Func<TModel, object>>[] sortKeySelectors, bool[] ascending)

{

SortKeySelectors = sortKeySelectors;

SortAscending = ascending;

return this;

}

public PagedQuery<TModel> OrderBy(IEnumerable<string> propertyNames, bool[] ascending)

{

return OrderBy(propertyNames.Select(NestedPropertyGet).ToArray(), ascending);

}

public static IOrderedQueryable<TModel> ObjectSort(IQueryable<TModel> entities, Expression<Func<TModel, object>> expression, bool ascending)

{

var unaryExpression = expression.Body as UnaryExpression;

if (unaryExpression != null)

{

var propertyExpression = (MemberExpression)unaryExpression.Operand;

var parameters = expression.Parameters;

if (propertyExpression.Type == typeof(DateTime))

{

var newExpression = Expression.Lambda<Func<TModel, DateTime>>(propertyExpression, parameters);

return ascending ? entities.OrderBy(newExpression) : entities.OrderByDescending(newExpression);

}

if (propertyExpression.Type == typeof(int))

{

var newExpression = Expression.Lambda<Func<TModel, int>>(propertyExpression, parameters);

return ascending ? entities.OrderBy(newExpression) : entities.OrderByDescending(newExpression);

}

if (propertyExpression.Type == typeof(decimal))

{

var newExpression = Expression.Lambda<Func<TModel, decimal>>(propertyExpression, parameters);

return ascending ? entities.OrderBy(newExpression) : entities.OrderByDescending(newExpression);

}

throw new NotSupportedException("Object type resolution not implemented for this type");

}

return ascending ? entities.OrderBy(expression) : entities.OrderByDescending(expression);

}

public PagedList<TModel> ExecuteOn(IQueryable<TModel> repository)

{

int totalItems = 0;

repository = ApplyFilter(repository);

if (PageSize > 0)

{

totalItems = repository.Count();

}

repository = ApplySorting(repository);

repository = ApplyPaging(repository);

return new PagedList<TModel>(repository.ToList(), Page, PageSize, totalItems);

}

private IQueryable<TModel> ApplyFilter(IQueryable<TModel> repository)

{

if(Condition != null)

{

repository = repository.Where(Condition);

}

return repository;

}

private IQueryable<TModel> ApplySorting(IQueryable<TModel> repository)

{

for (int i = 0; i < SortKeySelectors.Length; i++)

{

repository = ObjectSort(repository, SortKeySelectors[i], SortAscending[i]);

}

return repository;

}

private IQueryable<TModel> ApplyPaging(IQueryable<TModel> repository)

{

if(PageSize > 0)

{

repository = repository.Skip((Page - 1)*PageSize).Take(PageSize);

}

return repository;

}

private static Expression<Func<TModel, object>> NestedPropertyGet(string propertyChain)

{

var properties = propertyChain.Split('.');

var type = typeof(TModel);

var parameter = Expression.Parameter(type, "x");

Expression expression = parameter;

PropertyInfo propertyInfo = null;

foreach (var propertyName in properties)

{

propertyInfo = type.GetProperty(propertyName);

expression = Expression.Property(expression, propertyInfo);

type = propertyInfo.PropertyType;

}

if (propertyInfo != null && propertyInfo.PropertyType.IsValueType)

{

expression = Expression.Convert(expression, typeof(object));

}

return Expression.Lambda<Func<TModel, object>>(expression, parameter);

}

}

PagedList

Es una clase simple, sin lógica, que contiene los resultados de una consulta paginada.

public class PagedList<TModel> : IEnumerable<TModel>

{

public int PageNumber { get; private set; }

public int PageSize { get; private set; }

public int TotalItems { get; private set; }

private IList<TModel> Items { get; set; }

public PagedList(IList<TModel> items, int pageNumber, int pageSize, int totalItems)

{

Items = items;

PageNumber = pageNumber;

PageSize = pageSize;

TotalItems = pageSize == 0 ? items.Count : totalItems;

}

public IEnumerator<TModel> GetEnumerator()

{

return Items.GetEnumerator();

}

IEnumerator IEnumerable.GetEnumerator()

{

return GetEnumerator();

}

}

JqGridExtensions

Se encarga del mapeo entre los parámetros de JqGrid y la clase PagedQuery. Contiene el método ToPagedQuery, que crea un PagedQuery en base a los parámetros enviados por JqGrid, conteniendo los criterios de ordenamiento y filtrado, así como los datos relacionados con el paginado.

public static class JqGridExtensions

{

public static PagedQuery<TModel> ToPagedQuery<TModel>(this GridSettings settings)

{

var query = PagedQuery<TModel>.All().Size(settings.PageSize).FetchPage(settings.PageIndex);

if (settings.Where != null)

{

foreach (var rule in settings.Where.rules)

{

query.Condition = query.Condition.Where(rule.field, rule.data, rule.op);

}

}

var sortColumns = (settings.SortColumn + " " + settings.SortOrder).Split(',');

if (!String.IsNullOrWhiteSpace(settings.SortColumn) && sortColumns.Length > 0)

{

var propertyNames =

sortColumns.Select(x => x.Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries)[0].Trim())

.ToArray();

var ascending =

sortColumns.Select(

x => x.Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries)[1].Trim() == "asc")

.ToArray();

query = query.OrderBy(propertyNames, ascending);

}

return query;

}

private static Expression<Func<T, bool>> Where<T>(this Expression<Func<T, bool>> query,

string column, object value, string operation)

{

if (string.IsNullOrEmpty(column))

return query;

ParameterExpression parameter = Expression.Parameter(typeof (T), "p");

MemberExpression memberAccess = null;

Expression<Func<T, bool>> finalLambda = null;

foreach (var property in column.Split('.'))

{

memberAccess = Expression.Property

(memberAccess ?? (parameter as Expression), property);

}

if (value == null)

{

return query;

}

foreach (var val in value.ToString().Split(new[] {','}))

{

Expression<Func<T, bool>> lambda = null;

Expression condition = GetCondition(val, memberAccess, operation);

if (condition != null)

lambda = Expression.Lambda<Func<T, bool>>(condition, parameter);

finalLambda = finalLambda == null ? lambda : CombineOr(finalLambda, lambda);

//Si es string vacío también busca por NULL

if (val == string.Empty)

{

condition = GetCondition(null, memberAccess, operation);

if (condition != null)

lambda = Expression.Lambda<Func<T, bool>>(condition, parameter);

finalLambda = finalLambda == null ? lambda : CombineOr(finalLambda, lambda);

}

}

return query == null ? finalLambda : Combine(query, finalLambda);

}

private static Expression GetCondition(string val, MemberExpression memberAccess, string operation)

{

var filter = Expression.Constant

(

ChangeType(val, memberAccess.Type)

);

Expression condition = null;

switch (operation)

{

//equal ==

case "eq":

condition = Expression.Equal(memberAccess, Expression.Convert(filter, memberAccess.Type));

break;

//not equal !=

case "ne":

condition = Expression.NotEqual(memberAccess, filter);

break;

//begins with !=

case "bw":

condition = Expression.Call(memberAccess,

typeof(string).GetMethod("StartsWith", new[] { typeof(string) }),

Expression.Constant(val));

break;

//string.Contains()

case "cn":

condition = Expression.Call(memberAccess,

typeof(string).GetMethod("Contains"),

Expression.Constant(val));

break;

//not begins with !=

case "ew":

condition = Expression.Call(memberAccess,

typeof(string).GetMethod("EndsWith", new[] { typeof(string) }),

Expression.Constant(val));

break;

//less than <

case "lt":

condition = Expression.LessThan(memberAccess, filter);

break;

//less or equal <=

case "le":

condition = Expression.LessThanOrEqual(memberAccess, filter);

break;

//greater than >

case "gt":

condition = Expression.GreaterThan(memberAccess, filter);

break;

//greater or equal than >=

case "ge":

condition = Expression.GreaterThanOrEqual(memberAccess, filter);

break;

}

return condition;

}

private static Expression<Func<T, bool>> Combine<T>(Expression<Func<T, bool>> filter1, Expression<Func<T, bool>> filter2)

{

var rewrittenBody1 = new ReplaceVisitor(

filter1.Parameters[0], filter2.Parameters[0]).Visit(filter1.Body);

var newFilter = Expression.Lambda<Func<T, bool>>(

Expression.AndAlso(rewrittenBody1, filter2.Body), filter2.Parameters);

return newFilter;

}

private static Expression<Func<T, bool>> CombineOr<T>(Expression<Func<T, bool>> filter1, Expression<Func<T, bool>> filter2)

{

var rewrittenBody1 = new ReplaceVisitor(

filter1.Parameters[0], filter2.Parameters[0]).Visit(filter1.Body);

var newFilter = Expression.Lambda<Func<T, bool>>(

Expression.OrElse(rewrittenBody1, filter2.Body), filter2.Parameters);

return newFilter;

}

class ReplaceVisitor : ExpressionVisitor

{

private readonly Expression from, to;

public ReplaceVisitor(Expression from, Expression to)

{

this.from = from;

this.to = to;

}

public override Expression Visit(Expression node)

{

return node == from ? to : base.Visit(node);

}

}

private static object ChangeType(string value, Type conversionType)

{

if (conversionType == null)

{

throw new ArgumentNullException("conversionType");

}

if (conversionType.IsEnum)

{

return Enum.Parse(conversionType, value);

}

Guid result;

if (Guid.TryParse(value,out result))

{

return Guid.Parse(value);

}

if (conversionType.IsGenericType &&

conversionType.GetGenericTypeDefinition() == typeof(Nullable<>))

{

if (value == null)

{

return null;

}

var nullableConverter = new NullableConverter(conversionType);

conversionType = nullableConverter.UnderlyingType;

}

return Convert.ChangeType(value, conversionType);

}

}

 

Código completo

En el archivo zip encontrarán la solución completa del ejemplo en Visual Studio 2013.


¡Gracias Guillermo Vasconcelos por tu contribución!