Binding de colecciones en ASP.NET MVC (ii)

Bueno… En el post anterior vimos como el DefaultModelBinder esperaba los nombres de los campos para poder realizar el enlace entre los datos de la request y un parámetro de tipo colección en el controlador.

Pero vimos que había un pequeño detalle. Supongamos el siguiente método del controlador:

[HttpPost]
public ActionResult Index(IEnumerable<int> results)
{
return View();
}

El método recibe una colección de enteros. Vamos a crearnos una vista de prueba:

@using (Html.BeginForm())
{
for (int i = 0; i < 10; i++)
{
<text>Pregunta @i:</text>
@Html.RadioButton("[" + i + "]", 1);
@Html.RadioButton("[" + i + "]", 2);
@Html.RadioButton("[" + i + "]", 3);
<p />
}

<input type="submit" value="enviar!" />
}

Mostramos simplemente 30 (10*3) radiobuttons. Esto nos mostrará 10 filas de radiobuttons. Las radiobuttons de cada fila se llaman igual “[i]”, siendo i el índice de la fila, que es lo que espera el DefaultModelBinder.

Ahora fíjemonos que pasa si el usuario selecciona tan solo ALGUNAS de las radiobuttons:

image

Lo que recibimos en el controlador es:

image

Tan sólo recibimos las radiobuttons marcadas hasta la primera que el usuario no ha marcado. A partir de este punto el DefaultModelBinder deja de enlazar! Por eso recibimos los valores de [0] y [1] ya que [2] es el primer valor que el usuario no informa.

Como enlaza colecciones el DefaultModelBinder

Bien… os animáis a explorar un poco el DefaultModelBinder? Dejadme que os muestre que pasa, a grandes rasgos, cuando se enlaza una colección… Si no te interesan tanto los detalles de como funciona el DefaultModelBinder puedes saltar al siguiente apartado 😉

Así que, qué hace el DefaultModelBinder cuando debe enlazar el parámetro results? Simplificando, lo primero es mirar el tipo de este parámetro (IEnumerable<int>) y llamar al método CreateModel que debe devolver un objeto compatible con este tipo. La implementación por defecto devuelve List<T> si el tipo del modelo es IEnumerable<T>.

Una vez tiene el objeto (una List<int> vacía en nuestro caso) empieza a rellenarla. Esto se hace dentro de un método llamado BindComplexModel que entre otras cosas mira si el modelo es de tipo IDictionary<K,V>, un array o un IEnumerable<T>. Esos tipos tienen tratamientos “especiales”. Si no es ningún de estos tipos se asume que estamos enlazando un objeto.

Si estamos enlazando un IEnumerable<T> se llama a otro método de nombre UpdateCollection que es extremadamente simple. Hace dos cosas sólamente:

  1. Llama a un método GetIndexes para que devuelva que indices debe enlazar
  2. Por cada índice busca un valor en la request de nombre “[idx]” y lo intenta enlazar (llamando a BindModel de nuevo).

Centrémonos en este primer punto, el método GetIndexes. Lo “casi” único que hace es lo siguiente:

// just use a simple zero-based system
stopOnIndexNotFound = true;
indexes = GetZeroBasedIndexes();

Pone stopOnIdexNotFound a true y llama a GetZeroBasedIndexes(). Y que es GetZeroBasedIndexes()? Pues lo siguiente:

private static IEnumerable<string> GetZeroBasedIndexes() {
for (int i = 0; ; i++) {
yield return i.ToString(CultureInfo.InvariantCulture);
}
}

Un método que devuelve una colección infinita (entre comillas porque a Int32.MaxValue petaría).

Bien, ya tenemos los indices que vamos a mirar en la request: todos desde [0] hasta [Int32.MaxValue-1]

Ahora volvemos al código de UpdateCollection. Así es como recorre el bucle de índices:

foreach (string currentIndex in indexes) {
string subIndexKey = CreateSubIndexName(bindingContext.ModelName, currentIndex);
if (!bindingContext.ValueProvider.ContainsPrefix(subIndexKey)) {
if (stopOnIndexNotFound) {
// we ran out of elements to pull
break;
}
else {
continue;
}
}
// codigo para enlazar el elemento y añadirlo (.Add) a la colección
}

Básicamente, en aquel punto donde en la request (recordad que el DefaultModelBinder accede a la request siempre a través de la propiedad ValueProvider) no se encuentre el parámetro correspondiente al índice (en nuestro caso [idx]) dejará de enlazar (el break sale del foreach) y devuelve todo lo enlazado hasta entonces.

Bueno… hemos visto como enlaza el DefaultModelBinder una colección y que realmente una vez no haya el parámetro de índice requerido en la request se para de enlazar. Pero… no os he enseñado todo el código, me he dejado una pequeña parte.

Recordáis que antes he dicho que el método GetIndexes() lo “casi” único que hacía era llamar a GetZeroBasedIndexes()? Pues bien antes de hacer esto hace otra cosa… Antes busca si existe un campo en la request llamado “index”.

Este valor si existe, debe contener un string[] con todos aquellos índices que el DefaultModelBinder debe buscar en la request. Pemitidme ahora que os enseñe el código completo del método GetIndexes():

string indexKey = CreateSubPropertyName(bindingContext.ModelName, "index");
ValueProviderResult vpResult = bindingContext.ValueProvider.GetValue(indexKey);
if (vpResult != null) {
string[] indexesArray = vpResult.ConvertTo(typeof(string[])) as string[];
if (indexesArray != null) {
stopOnIndexNotFound = false;
indexes = indexesArray;
return;
}
}
// just use a simple zero-based system
stopOnIndexNotFound = true;
indexes = GetZeroBasedIndexes();

No os perdáis en los detalles. Básicamente lo que hace es:

  1. Si existe un valor de request llamado “index” este valor debe contener un string[] que contendrá los índices a buscar. En este caso la variable stopOnIndexNotFound se pone a false, por lo que el método UpdateCollection no se parará cuando no encuentre un valor del array. Simplemente saltará al siguiente
  2. Si dicho valor no existe, hace lo que habíamos visto: pone la variable stopOnIndexNotFound a true y devuelve la colección de índices infinita empezando por 0.

El valor de request “index”

Así pues la solución consiste en añadir un campo en la request (en nuestro caso en el formulario) cuyo valor sea un string[] con los nombres de todos los campos índice 🙂

¿Y como se envia un string[] desde HTML? Pues muy sencillo, enviando N veces un campo con el MISMO name. Fijaos como nos queda la vista ahora:

@using (Html.BeginForm())
{
for (int i = 0; i < 10; i++)
{
<text>Pregunta @i:</text>
@Html.RadioButton("[" + i + "]", 1);
@Html.RadioButton("[" + i + "]", 2);
@Html.RadioButton("[" + i + "]", 3);
<input type="hidden" name="index" value="@i" />
<p />
}

<input type="submit" value="enviar!" />
}

Fijaos en el <input type=”hidden”> con name index que está dentro del for. En el HTML generado habrá 10 hiddens todos con el atributo “name” con el mismo valor “index” y cada uno con un valor distinto (de 0 a 9). Esto, a nivel del DefaultModelBinder, se recibe como un string[].

Bueno… y que ocurre ahora, si mando exactamente lo mismo que la vez anterior? Pues esto es lo que recibimos en el controlador:

image

Fijaos, que ahora recibimos 7 valores, que se corresponden a las 7 filas con alguna radiobutton marcada.

Vale, vale, vale… ya os oigo decir: “Sí, todo esto está muy bien, pero tampoco me sirve de nada. Aquí había 10 preguntas (0-9) y el usuario ha marcado sólo 7. Tengo las 7 respuestas ok, pero los índices son incorrectos!”. Efectivamente, vemos recibo una coleccción de 7 ints (las 7 respuestas) pero no se cuales han sido las que se han quedado en blanco! Yo había dejado sin marcar la #2, la #6 y la #8. Como puedo saber esto?

La respuesta es que tranquilos, que sólo hemos mirado en un lado, la respuesta completa la tenemos en otro. Efectivamente, el DefaultModelBinder nos ha creado una colección con los 7 valores entrados por el usuario. Pero puedo saber exactamente a que posición se corresponde cada valor? Pues sí, gracias a ModelState:

image

Fijaos en el valor de ModelState.Keys. Lo véis? Eso son las claves (los nombres) de los valores de la request. Exactamente! Con esto podemos hacer el mapeo:

  1. results[0] es el valor de la request «[0]”
  2. results[1] es el valor de la request “[1]”
  3. results[2] es el valor de la request “[3]” <—No hay ModelState.Keys con valor “[2]”, lo que significa que la fila #2 se había dejado sin ninguna radio marcada.

Lo veis? 😉

Por supuesto, todo esto se complica si nuestro controlador recibe varios parámetros o bien recibe una colección que está como propiedad de un objeto, pero no se complica demasiado, no os creais. En un siguiente post lo veremos para dejarlo claro 🙂

Y finalmente es posible que digas: “Pues, perdón pero eso no me gusta nada!, No podría el ModelBinder devolverme un array con las posiciones rellenadas y con los índices correctos?”

Bueno… pues poder, se puede pero ya cuesta un poco más de trabajo. pero tranquilos que veremos como… pero de momento, basta por hoy, no? 😀

Un saludo a todos!

6 comentarios sobre “Binding de colecciones en ASP.NET MVC (ii)”

  1. Interesante! Quedo a la ansiosa espera de tu 3er entrega (donde espero al fin poder aprender a bindear correctamente una colección que está como propiedad de un objeto)

    Saludos!!

  2. Buenas noches, tengo una pregunta con resecto al tema.
    Necesito llenar una (table) con un conjunto de datos, debo mostrar un checbox para que puede seleccionar varios datos de la tabla.
    como puedo saber que checbox estan seleccionados y que tipo de objeto deberia recibir en el controlador?

    Gracias
    Saludos!!!

  3. @Laura
    Muchas gracias por tu comentario! A ver si puedo sacarlo en breve 🙂

    @Camilo,
    Si lo que envias es una lista de checkboxes, tu controlador debe recibir un IEnumerable donde x es un tipo de datos que sea convertible desde la cadena que tengas en el value.
    Es decir si tienes un

    con:

    Tu controlador puede recibir un IEnumerable.

    Pero, en este caso, el problema lo tienes en que los browsers NO ENVIAN información alguna sobre las checkboxes NO seleccionadas. Es decir si tienes algo como:




    Y pulsas el botón de submit, la URL de destino es simplemente http://localhost:12345. Si seleccionas la SEGUNDA check pero NO la primera entonces es http://localhost:12345?%5B1%5D=2 sin ninguna información de que existía una checkbox [0].
    Esto, como hemos visto, el DefaultModelBinder no es capaz de enlazarlo.

    Pero, como también hemos visto en este post, se puede medio-solucionar con el parámetro index. Es decir en este caso añadiríamos un y el model binder sería capaz de enlazar los datos. De todos modos recuerda que en el controlador recibirás un IEnumerable con un sólo valor. Para saber si corresponde a la primera ([0]) o a la segunda ([1]) checkbox, deberás usar ModelState.Keys como se comenta.

    Hay OTRA posibilidad y es la que veremos en el siguiente artículo (que espero que no tarde mucho!) que será usar un Model Binder propio para estos casos! 😉

    Saludos y gracias por comentar! 😀

  4. Buenos dias, este es el codigo que tengo y como lo solucione por el momento porque creo que no es la mejor forma.
    Es razonable hacerlo asi:

    Polla: en Colombia le dicen polla a una cantidad de apuestas sobre diiferentes partidos.

    Vista:
    @using (Html.BeginForm())
    {

    @Html.DropDownList(«ddlPolla», new SelectList(ViewBag.Pollas, «IdPolla», «Nombre»), «Seleccione la polla»)
    Seleccione la Polla

    }
    en el div divUsuariosPolla cargo una vista parcial dependiendo el valor del DropDownList

    @{
    foreach (var u in ViewBag.Usuarios)
    {

    }
    }

    Nombre Pago
    @Html.ActionLink(«Eliminar usuario polla», «EliminarUusarioPolla», new { id = u.GetType().GetProperty(«IdUsuario»).GetValue(u, null) }) @{string id = u.GetType().GetProperty(«IdUsuarioPolla»).GetValue(u, null).ToString();}
    @Html.CheckBox(id)
    @u.GetType().GetProperty(«Nombre»).GetValue(u, null) @{
    bool pago = @u.GetType().GetProperty(«Pago»).GetValue(u, null);
    }
    @Html.CheckBox(«chkpago», pago, new { @disabled = true })

    Controlador
    [HttpPost]
    public ActionResult UsuariosPolla(FormCollection formulario)
    {
    int idPolla;
    int.TryParse(((string[])formulario.GetValue(«ddlPolla»).RawValue)[0], out idPolla);
    for (int i = 0; i < formulario.Keys.Count; i++) { string[] resultado = formulario.GetValue(formulario.Keys[i]).AttemptedValue.Split(new char[] { ',' }); if (resultado.Length == 2 && idPolla != 0) { if (resultado[0] == "true") { int idUsuario; int.TryParse(formulario.Keys[i], out idUsuario); BLLPolla.BLLPollas.EliminarUsuarioPolla(idPolla, idUsuario); } } } return RedirectToAction("UsuariosPolla"); } Gracias Saludos!!!

Deja un comentario

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