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!

Binding de colecciones en ASP.NET MVC

Buenas! Hoy voy a comentar un temilla que me comentó un colega el otro día y que puede dar algunos quebraderos de cabeza: el binding de colecciones.

Supongamos el siguiente viewmodel:

public class QuestionModel
{
public int IdQuestion {get; set;}
public string Text { get; set; }
public int IdAnswer { get; set; } // Id de la respuesta seleccionada
public IEnumerable<Answer> Answers { get; set; }
}

public class Answer
{
public int IdAnswer{ get; set; }
public string Text { get; set; }
}

Básicamente una pregunta contiene un Id, un texto y una lista de respuestas. Bien, supongamos que tenemos un método en el controlador que nos cree una lista de 10 preguntas, cada una de las cuales con 3 posibles respuestas, y la mande a una vista:

public ActionResult Index()
{
var questionModelList = new List<QuestionModel>();
for (var i = 1; i <= 10; i++)
{
questionModelList.Add(new QuestionModel()
{
IdQuestion = i,
Text = "Texto pregunta " + i,
IdAnswer = 0,
Answers = new List<Answer>() {
new Answer() {Text = "Respuesta A de " + i, IdAnswer = 1},
new Answer() {Text = "Respuesta B de " + i, IdAnswer = 2},
new Answer() {Text = "Respuesta C de " + i, IdAnswer = 3} }
});
}
return View(questionModelList);
}

Al igual que a la vista le mandamos una List<QuestionModel>, esperamos que eso sea lo que la vista nos devuelva en el controlador:

[HttpPost]
public ActionResult Index(List<QuestionModel> questionModelList)
{
// codigo...
}

Bueno, ahora veamos una posible implementación de la vista Index:

@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@foreach (var question in Model)
{
<div>
@Html.LabelFor(x=>question.Text, question.Text)
@foreach (var answer in question.Answers)
{
@Html.RadioButtonFor(m => answer.Text, answer.IdAnswer)
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}

Buenooo… parece una implementación correcta no? Iteramos sobre todas las preguntas y por cada pregunta generamos una label y tantas radio buttons como respuestas tenga la pregunta… El problema es que… no es correcta! 🙂

Veamos el código HTML que nos genera:

<div>
<label for="question_Text">Texto pregunta 1</label>
<input id="answer_Text" name="answer.Text" type="radio" value="1" />
<input id="answer_Text" name="answer.Text" type="radio" value="2" />
<input id="answer_Text" name="answer.Text" type="radio" value="3" />
</div>
<div>
<label for="question_Text">Texto pregunta 2</label>
<input id="answer_Text" name="answer.Text" type="radio" value="1" />
<input id="answer_Text" name="answer.Text" type="radio" value="2" />
<input id="answer_Text" name="answer.Text" type="radio" value="3" />
</div>
<!-- Y así sucesivamente... :p -->

Fijaos que tanto el id como el name de todas las radiobutton son iguales! Y eso? Eso es porque name e id se sacan de la lambda expresion que se pasa como parametro al helper Html.RadioButtonFor<>. Y esa lambda es siempre la misma. Bueno… al menos el error es fácil de detectar porque una vez marqueis una radio button, al marcar cualquier otra de cualquier otra pregunta la primera se seleccionará. Recordad que HTML agrupa las radiobuttons según el atributo “name”, y si todas tienen el mismo, todas las radiobutton forman un solo grupo, y por lo tanto sólo una puede estar seleccionada.

Nota: Recordad la clave: la expresión lambda que se pasa a Html.RadioButtonFor<> es la que se usa para generar el valor del atributo name.

La siguiente pregunta que debemos hacernos es… cual debe ser el valor correcto de los atributos name de cada radiobutton? Para eso que ver como el DefaultModelBinder enlaza los datos cuando hay colecciones, y por suerte la regla es muy sencilla. Si estamos enlazando datos de una colección el DefaultModelBinder espera que el valor del atributo name tenga el formato: parametro[idx]. Es decir, analicemos la estructura de clases que el controlador espera.

El controlador espera una List<QuestionModel> llamada questionModelList. De ese modo:

  • questionModelList[0].Text se enlazaría con la propiedad Text del primer elemento de la lista.
  • questionModelList[1].Text se enlazaría con la propiedad Text del primer elemento de la lista.
  • questionModelList[0].Answers[0].Id se enlazaria con el valor de la propiedad Id de la primera respuesta del primer elemento de la lista.
  • questionModelList[0].Answers[1].Id se enlazaria con el valor de la propiedad Id de la segunda respuesta del primer elemento de la lista.

¿Lo veis? El DefaultModelBinder espera que el valor del atributo name sea “lo mismo que usaríamos en C#”.

Nota: Si te “hace daño a los ojos” lo de questionModelList al principio del atributo name (recuerdo que esto es el nombre del parámetro), que sepas que si el controlador sólo recibe una colección (como en nuestro caso) puedes eliminar el nombre del parámetro. En nuestro caso podriamos usar [0].Text como valor del atributo name p.ej.

Así, p.ej. podríamos modificar la vista para que quede parecida a:

@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@for (int idx = 0; idx < Model.Count; idx++)
{
<div>
<label>@Model[idx].Text</label>
@for (int idxAnswer = 0; idxAnswer < Model[idx].Answers.Count(); idxAnswer++)
{
var lstAnswers = Model[idx].Answers.ToList();
<input type="radio"
name="@string.Format("questionModelList[{0}].Answers[0].IdAnswer", idx)"
value="@lstAnswers[idxAnswer].IdAnswer" />
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}

Eso genera un HTML como el siguiente:

<div>
<label>Texto pregunta 1</label>
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="1" />
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="2" />
<input type="radio"
name="questionModelList[0].Answers[0].IdAnswer" value="3" />
</div>
<div>
<label>Texto pregunta 2</label>
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="1" />
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="2" />
<input type="radio"
name="questionModelList[1].Answers[0].IdAnswer" value="3" />
</div>

Ahora los campos siguen la convención que espera el DefaultModelBinder. Con lo que los datos ahora si que serán recibidos por el controlador.

Bueno… ¿pero esto que sentido tiene?

Si os fijáis veréis que las radiobuttons de cada pregunta tienen todas el mismo name. Eso es correcto ya que sólo una respuesta puede seleccionarse por cada pregunta. Así todas las radiobuttons de la primera pregunta tienen el valor de questionModelList[0].Answers[0].IdAnswer y así sucesivamente.

Así pues, cual es la información que viaja del cliente al servidor? Aunque tengamos tres radiobuttons por pregunta, todas tienen el mismo name y sólo una viajará. Es decir, dadas las 10 radiobuttons viajarán 10 valores del cliente al servidor:

  • questionModelList[0].Answers[0].IdAnswer
  • questionModelList[1].Answers[0].IdAnswer
  • questionModelList[9].Answers[0].IdAnswer

Es decir, para el servidor tendremos una lista de 10 preguntas cada una de las cuales tendrá UNA sola respuesta (la seleccionada por el usuario). Y eso es exactamente lo que se bindea en el controlador:

image

Fijaos como p.ej. el campo Text de la pregunta es “null”. Normal, la vista no lo está enviando al controlador.

Parémonos un momento en este punto. Desde el controlador hemos mandado a la vista toda una List<QuestionModel> llena. Es decir la vista si que recibe el texto de las preguntas y las respuestas… Por que cuando estamos de vuelta en el controlador hemos perdido esta información?

Si te estás preguntando esto: bienvenido al mundo stateless de la web. Efectivamente el controlador envía toda una List<QuestionModel> a la vista y la vista usa esta información para generar un HTML, que es mandado al cliente. En este punto el trabajo del servidor ha terminado. El controlador muere y será creado de nuevo cuando se reciba otra petición del browser. Esta otra petición llega cuando pulsamos el botón de submit, pero el controlador sólo recibe lo que la vista envía. Y la vista sólo envia lo que está en el formulario: los IDs de las respuestas seleccionadas. La vista no envia los textos ni de la pregunta, ni de la respuesta. Por eso no los tenemos de vuelta. Recordad: la web es stateless. Si venís de Webforms, webforms os ocultaba esto y os hacía creer que la web es statefull. Pero MVC no oculta nada de nada: lo que enseña es, simplemente, lo que hay.

Bueno… dicho esto, ahora preguntaos, si tiene sentido que este método del controlador reciba una lista de QuestionModel. El tema es que si esta vista tiene que enviar las respuestas eso debería ser lo que debería esperar el controlador. Es decir, la vista recibe un List<QuestionModel> pero devuelve un array con los IDs de las preguntas seleccionadas… Total… la vista no va a devolver el texto de las preguntas y las respuestas al controlador, no? Si lo que la vista devuelve son los IDs de las respuestas seleccionadas esto es lo que debería recibir el controlador:

image

Fijaos simplemente que el controlador recibe una colección con los IDs de las respuestas seleccionados. Así idRespuestas[0] el ID de la respuesta seleccionada de la primera pregunta y así sucesivamente. La información del texto de las preguntas (en caso de ser necesaria) la obtendría el propio controlador (leyéndola de la BBDD, de la cache, de donde fuera).

Y como nos quedaría la vista? Pues como ahora no mandamos una estructura tan compleja al controlador, generar el nombre de los atributos name es mucho más fácil:

@model List<Ejemplo.ConRadioButtonNormal.Models.QuestionModel>
@{
ViewBag.Title = "Index";
}
@using (Html.BeginForm())
{
<div>
@for (int idx = 0; idx < Model.Count; idx++)
{
<div>
<label>@Model[idx].Text</label>
@for (int idxAnswer = 0; idxAnswer < Model[idx].Answers.Count(); idxAnswer++)
{
var lstAnswers = Model[idx].Answers.ToList();
<input type="radio"
name="@string.Format("[{0}]", idx)"
value="@lstAnswers[idxAnswer].IdAnswer" />
}
</div>
}
</div>
<p><input type="submit" value="Submit" /></p>
}

Vale, fijaos en dos cosillas:

  1. La vista sigue recibiendo una List<QuestionModel>. Normal, porque esto es lo que el controlador le sigue mandando. Pero eso NO implica que la vista deba mandar de vuelta esto mismo!
  2. El valor de los atributos “name” de las distintas radiobutton, es simplemente [0],[1],… y así sucesivamente (recordad como hemos comentado antes que si el controlador recibe una sola colección no es necesario poner el nombre del parámetro en el atributo name).

Una nota final…

Bueno, hemos visto como funciona el binding de colecciones en ASP.NET MVC, pero os quiero comentar sólo una cosilla más 🙂

Que ocurriria si le enviasemos al controlador los siguientes campos (sus valores son irrelevantes)?

  1. [0], [1], [3], [4]
  2. [1], [2], [3], [4]

En el primer caso vemos que falta [2]. Entonces el DefaultModelBinder se para en este punto. Es decir el controlador recibirá una colección con dos elementos (los valores de [0] y [1]. Los valores de [3] y [4] se han perdido).

En el segundo caso vemos que falta el primer elemento [0]. Entonces el DefaultModelBinder ni enlaza el parámetro. Es decir el controlador no recibirá una colección vacía, no… recibirá null en el parámetro.

En un próximo post veremos como podemos evitar esto 😉

Un saludo!

ds