Binding de colecciones en ASP.NET MVC (iii)

Bueno… vamos a seguir viendo el tema de binding de colecciones con ASP.NET MVC. En los dos posts anteriores hemos visto:

En este post vamos a ver como enlazar una colección de N elementos, de los cuales sólo nos llegan un determinado número, pero queremos fácilmente saber cuales son. Es decir, si nos llega sólo el primer elemento, el segundo y el octavo, recibir una lista con los ocho elementos, todos ellos a “null” (o un valor por defecto) excepto los informados (el primero, el segundo y el octavo en nuestro caso).

Si usamos el DefaultModelBinder esto no pasa: en los posts anteriores hemos visto como en el mejor de los casos (usando el parámetro index), recibimos sólo una colección con los tres elementos, y debemos usar ModelState.Keys para saber cuales son los índices reales informados. Es decir, si la vista sólo nos informa del primer, segundo y octavo elementos en el controlador recibimos una colección de tres elementos (los tres informados). Para saber que el tercer elemento (p.ej.) de dicha colección se corresponde al octavo índice real debemos usar ModelState.Keys. Vamos a ver ahora como podemos hacerlo para recibir, en este caso, una colección con los ocho elementos. De estos ocho, tan sólo el primer, el segundo y el octavo tendrán valor (el resto, un valor por defecto).

La solución es simple, y pasa por crearnos un Custom Model Binder 🙂 Crear un model binder propio parece muy complejo, pero se trata de implementar una interfaz con un solo método (BindModel). Sí, si miras el código del DefaultModelBinder te parecerá enorme y complejo, pero piensa que el DefaultModelBinder está pensado para enlazar cualquier cosa, y nosotros vamos a hacer un model binder preparado para enlazar sólamente colecciones (IEnumerable<T> en nuestro caso).

Así pues, vamos a hacer este custom model binder, especializado en colecciones. Vamos a imitar en todo al Default Model Binder, excepto en que nosotros vamos a devolver una colección con el tamaño real (no solo con los elementos informados).

Os pongo primero el código del model binder y lo discutimos (por supuesto, si queréis preguntar algo concreto sobre el código, adelante!):

public class CollectionBinder : IModelBinder
{
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{

var model = CreateModel(bindingContext) as IList;
var prefix = bindingContext.ModelName;
var indexesKey = bindingContext.FallbackToEmptyPrefix ?
bindingContext.ValueProvider.GetValue("index") :
bindingContext.ValueProvider.GetValue(string.Format("{0}.index", prefix));
var indexes = indexesKey == null ? AllIndexes() : EnumerableFromIndexes(indexesKey.RawValue as string[]);
var genericType = GetGenericTypeOfModel(bindingContext);


foreach (var index in indexes)
{
var value = bindingContext.FallbackToEmptyPrefix ?
bindingContext.ValueProvider.GetValue(string.Format("[{0}]", index)) :
bindingContext.ValueProvider.GetValue(string.Format("{0}[{1}]", prefix, index));

if (value != null)
{
var valueConverted = Convert.ChangeType(value.AttemptedValue, genericType);
model.Add(valueConverted);
}
else
{
if (indexesKey == null) break;
else
{
model.Add(genericType.IsValueType
? Activator.CreateInstance(genericType)
: null);
}
}
}


return model;
}

private object CreateModel(ModelBindingContext bindingContext)
{
var genericType = GetGenericTypeOfModel(bindingContext);
var listOfTType = typeof(List<>).MakeGenericType(new Type[] { genericType });
return Activator.CreateInstance(listOfTType);
}

private Type GetGenericTypeOfModel(ModelBindingContext bindingContext)
{
var type = bindingContext.ModelType;
var genericTypes = type.GetGenericArguments();
return genericTypes.FirstOrDefault();
}

private IEnumerable<int> AllIndexes()
{
for (int i = 0; i < Int32.MaxValue; i++)
{
yield return i;
}
}


private IEnumerable<int> EnumerableFromIndexes(string[] indexesToUse)
{
if (indexesToUse != null)
{
foreach (var token in indexesToUse)
{
yield return Int32.Parse(token);
}
}
}
}

Como funciona el siguiente código? Pues nuestro collection binder hace lo siguiente:

  1. Crea un objeto para representar el modelo. Dicho objeto será siempre una List<T>, siendo T el parámetro genérico del IEnumerable del modelo.
  2. Mira si existe el parámetro index. Si dicho parámetro existe, lo usa para saber los indices reales de la colección.  Es decir, si indexes vale “0,1,2,3,4,5” (p.ej.) nuestro model binder va a devolver siempre una colección de 6 elementos (del 0 al 5) con independencia de los elementos reales informados en la vista. Esto es para imitar lo que hace el DefaultModelBinder y que vimos en el post anterior.
  3. Busca en los valueproviders los valores para todos los índices. Si el parámetro “index” no existía, todos los indices son literalmente “todos” (de 0 a Int32.MaxValue-1). Si el parámetro index no existe nos paramos cuando falta un elemento (porque si no, siempre devolveríamos una colección de Int32.MaxValue elementos!). Por su parte si el parametr index existe, iteramos sólo sobre sus valores, y si el valor no existe, lo añadimos al modelo con el valor por defecto del tipo genérico. Es decir, si index vale “0,1,2,3,4,5” y la vista no nos informa del valor del índice 3, pondremos el valor por defecto en el índice 3 y continuaremos hasta llegar a 5.

El uso de los value providers para obtener los valores nos independiza de si dichos valores vienen por GET, POST o lo que sea. De esta manera el Model Binder es independiente de la request de http.

Este CollectionBinder está preparado para trabajar con cualquier tipo de IEnumerable. Para usarlo, debemos registrarlo en global.asax:

ModelBinders.Binders[typeof(IEnumerable<string>)] = new CollectionBinder();

Con esto, lo hemos registrado para que los IEnumerable<string> se enlacen usando nuestro model binder!

¿Lo probamos? Para ello nos creamos un modelo:

public class FooModel
{
public string Name { get; set; }
public int Age { get; set; }
public IEnumerable<string> Data { get; set; }
}

Y luego un controlador con un método para recibir un FooModel:

public ActionResult Index()
{
var model = new FooModel();
model.Age = 10;
model.Name = "Nombre";
model.Data = new List<string> {"cero", "uno", "dos", "tres", "cuatro"};
return View(model);
}
[HttpPost]
public ActionResult Index(FooModel model )
{
int i = 0;
// Codigo...
}

Vamos ahora a hacer una vista para editar nuestro FooModel:

@using BindingColecciones3.Models
@model FooModel
<!DOCTYPE html>
<html>
<head>
<title>title</title>
</head>
<body>
<div>
@using (Html.BeginForm())
{
<div>
@Html.LabelFor(x => x.Name)
@Html.EditorFor(x => x.Name)
<br />
@Html.LabelFor(x => x.Age)
@Html.EditorFor(x => x.Age)
</div>
<ul>
@for (var idx = 0; idx < Model.Data.Count(); idx++)
{
<li>Checkbox #@idx:
<input type="checkbox" name="Data[@idx]" value="@Model.Data.Skip(idx).First()"/>
<input type="hidden" name="Data.index" value="@idx"/>
</li>
}
</ul>
<input type="submit" />
}
</div>
</body>
</html>

Fijaos en como creamos las checkboxes: El atributo name de cada checkbox es Data[0], Data[1], Data[2]… Eso es porque Data es el nombre de la propiedad IEnumerable<string> de nuestro modelo. El atributo value de cada checkbox será la cadena que se enlazará en el modelo. Si p.ej. sólo marcamos la tercera checkbox (cuyo value es “dos”, eso es lo que recibiremos en el controlador:

image

Fijaos que, a diferencia del CustomModelBinder, lo que recibimos ahora es una colección de 6 elementos (0-5) y sabemos exactamente cual era la única checkbox marcada. Esa misma vista, pero usando el DefaultModelBinder para enlazar los datos, devolvería lo siguiente al controlador (tal y como vimos en el post anterior):

image

Y deberíamos usar ModelState.Keys para saber que este “dos” es el valor de la tercera checkbox marcada.

Recordad que esto ocurre porque los navegadores no envían valores para una checkbox NO marcada. Es decir, en HTML las checkboxes no tienen el valor de true o false. Tienen sólo el valor que ponga en su value si están marcadas o no existen si no están marcadas.

Y finalmente una consideración sobre el código de este CollectionModelBinder: Tiene algunas limitaciones, alguna que otra cosa que se podría mejorar, alguna incongruencia (sobretodo en la gestión del parámetro index) y cosas que se le podrían añadir… Os dejo que vayáas pensando cuales… y alguna de ellas las veremos en un siguiente post, que por hoy, es suficiente, no? 😉

Un saludo!

2 comentarios sobre “Binding de colecciones en ASP.NET MVC (iii)”

  1. Gracias Eduard por otro esclarecedor post de ésta serie 🙂
    Me queda una duda, ¿cómo sería si quisiera bindear un objeto donde una de las propiedades del objeto es una colección de objetos no ordenados?

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *