De cómo en AspNetMVC prevalece QueryString sobre el propio Modelo

Imaginemos una página MVC muy sencilla: un buscador con dos datos, el texto a buscar y una casilla para indicar si se quiere coincidencia estricta de mayúsculas o no. La búsqueda se ejecuta mediante un botón y los resultados se muestran en la misma página tras un roundtrip completo al servidor, sin AJAX. Para construir esta página, vamos a elaborar sus tres piezas M.V.C.

Modelo

Una clase ViewModel con los parámetros de la búsqueda y con una lista de resultados.

public class BuscarViewModel
{
    public string Texto { get; set; }
    public bool EnMayusculas { get; set; }
    public IEnumerable<dynamic> Resultados { get; set; }
}

Controlador

Una acción Buscar de tipo GET que recibe los parámetros de la búsqueda. Por simplicidad, uso el mismo ViewModel como parámetro de entrada, para aprovechar el trabajo del ModelBinder de MVC. La acción rellena el valor de Resultados, en teoría considerando los parámetros de búsqueda, aunque en este caso he usado valores de ejemplo.

Imaginemos también una peculiar regla de negocio que nos exige que la casilla “Coincidir mayúsculas” se devuelva siempre desmarcada, incluso si ha sido marcada en la búsqueda anterior. Para ello, en el modelo recibido la establecemos a falso.

public ActionResult Buscar(BuscarViewModel buscarViewModel)
{
    buscarViewModel.EnMayusculas = false;                         //Siempre se pone a falso
    var ejemplo = new {Texto = "Ejemplo" };
    buscarViewModel.Resultados = Enumerable.Repeat(ejemplo, 50);  //Ficticio
    return View(buscarViewModel);
}

Esta acción responde a las siguientes URI (suponiendo un controlador EntidadController):

  • /Entidad/Buscar
  • /Entidad/Buscar?Texto=abc&EnMayusculas=true

Vista

Una vista muy sencilla podría ser:

@model MvcCheckBoxForFail.Models.BuscarViewModel
@{
    ViewBag.Title = "Buscar";
    var grid = new WebGrid(Model.Resultados);
}

<h2>Búsqueda</h2>
@using (Html.BeginForm("Buscar", "Entidad", FormMethod.Get)) {
    <fieldset>
        <legend>Buscar @Model.Texto en mayúsculas @Model.EnMayusculas</legend>

        <div>
            Texto
            @Html.EditorFor(model => model.Texto)
            Mayúsculas
            @Html.EditorFor(model => model.EnMayusculas)
            <input type="submit" value="Buscar" />
        </div>

        @grid.GetHtml()

    </fieldset>
}

Sus elementos principales son:

  • Un WebGrid nativo de MVC para mostrar los resultados con una sencilla paginación en servidor.
  • Un Form con método GET.
  • Los campos para el texto y para forzar la coincidencia de mayúsculas, generados ambos con EditorFor.

Problemática

Si probamos esta sencilla página, nos encontraremos con un curioso comportamiento: no sigue la regla de que la casilla de forzar mayúsculas debe salir siempre desactivada. Por el contrario, mantiene el valor usado para la búsqueda. Pero nosotros hemos asignado false claramente en el controlador, por lo que ¿quién es el responsable de que se muestre la casilla activada? A este nivel, el culpable es el método EditorFor, que no usa el valor que trae el modelo (sí, de ahí el resaltado amarillo, soy muy malo para mantener el suspense). Más adelante depuraremos mejor las responsabilidades.

Antes de eso, probamos con otras opciones. Por ejemplo, en lugar de EditorFor, usaremos CheckBoxFor:

@Html.CheckBoxFor(model => model.EnMayusculas)

El comportamiento, como era de esperar, es idéntico. Así que vamos un paso más allá y usamos el método CheckBox:

@Html.CheckBox("EnMayusculas", Model.EnMayusculas)

Para nuestra sorpresa, el comportamiento sigue siendo el mismo, aún cuando Model.EnMayusculas es siempre falso. Pero es más, si escribimos:

@Html.CheckBox("EnMayusculas", false)

Incluso indicando el valor de false explícitamente, el input se renderiza marcado (checked) en ciertas ocasiones. ¿Cómo es esto posible?

Primera explicación

En primer lugar, ya hemos identificado en qué casos se activa la casilla: cuando estaba marcada al hacer la búsqueda, es decir, cuando se recibe en la QueryString un EnMayusculas=true.

NOTA: En la QueryString se recibirá siempre un EnMayusculas=false, independientemente de que se marque o no la casilla. Esto es debido al hidden que genera CheckBoxFor. Para más información sobre este comportamiento, ver esta respuesta de Jeremy.

¿Qué se deduce de aquí? Pues que al usar los métodos del helper Html para renderizar un control (no sucede sólo con el CheckBox, probadlo con otros), si el nombre indicado existe en la QueryString recibida, se usará ese valor independientemente de su valor en el modelo o del valor estricto que le pasemos al helper. Hay más información sobre este comportamiento en esta incidencia respondida por RanjiniM de forma contundente: “Este es el comportamiento esperado en ASP.NET MVC”.

Soluciones

Ante esto, pueden buscarse distintas soluciones. Yo voy a plantear las dos más extremas.

La primera es dejar de usar el helper CheckBox e insertar un input en HTML directamente. Incluso podemos elaborar nuestro propio helper que evite este comportamiento.

Pero antes veamos la segunda solución, que es la que propone RanjiniM en la respuesta anterior: excluir la propiedad en cuestión del uso del ModelBinder. La firma de la acción incluirá un nuevo atributo, quedando así en nuestro controlador:

public ActionResult Buscar([Bind(Exclude="EnMayusculas")] BuscarViewModel buscarViewModel)

El atributo se aplica al parámetro, no al método completo, y define la propiedad (o propiedades, separadas por comas) para las que queremos evitar el comportamiento descrito. Una vez establecido este atributo, el código original (usando CheckBoxFor) funciona correctamente, y la casilla sale desmarcada en todos los casos.

ModelBinder y ValueProvider

Con la ayuda de Luis Ruiz Pavón y de Eduard Tomàs he revisado cómo puede afectar el orden de definición de los ValueProvider a esta incidencia, pero mis pobres conocimientos no me han permitido llegar a una conclusión.

Yo entiendo que tanto el ModelBinder como los ValueProvider se utilizan a la hora de generar el modelo, es decir, de construir la instancia que se pasa como argumento a la acción Buscar. Pero una vez generada esa instancia de BuscarViewModel, no consigo entender por qué vuelve a prevalecer el valor de QueryString sobre lo que pone en el modelo. Si los valores de QueryString ya se han trasladado al modelo, lo lógico después es trabajar con el modelo, que puede haber sufrido cambios como en nuestro supuesto.

La intención de toda esta exposición es doble: en primer lugar, servir de ayuda a quien pueda encontrarse esta misma incidencia; y por otro lado, tratar de comprender mejor la justificación de este comportamiento, que según afirma RanjiniM no es un bug sino el comportamiento esperado (a no ser que estemos disfrazando un bug de feature como tantas veces 😉 Por eso agradezco vuestros comentarios y opiniones al respecto.

Un placer.

Anuncios
Esta entrada fue publicada en Sin categoría. Guarda el enlace permanente.

2 respuestas a De cómo en AspNetMVC prevalece QueryString sobre el propio Modelo

  1. Juam Quojanp dijo:

    Muy buen post. Mil gracias.

  2. Hola, Pablo!

    Buen post, e interesante tema 🙂

    A falta de estudiarlo en profundidad, no creo que influya el binding ni los value providers. Más bien pienso que el valor “persistente” se está tomando desde el ModelState, que es el mecanismo que permite mantener el estado de los campos de formulario entre peticiones (y que es llenado durante el binding). El helper simplemente obtiene el valor desde el ModelState y es lo que muestra como valor por defecto del control (el checkbox en este caso, pero es igual con otros).

    Y probablemente la mayoría de veces este comportamiento por defecto sea razonable; lo normal es que interese mantener en un campo el valor introducido originalmente por el usuario cuando envió el formulario, aunque éste no cumpla reglas de validación, integridad, etc. Es decir, imagina que en un campo de formulario asociado a una propiedad de tipo “int” yo introduzco un valor “Hola”; al enviar el formulario, si éste vuelve a aparecer (como en tu ejemplo) lo lógico es que aparezca “Hola” en el editor, y no un “0” (que sería el valor del modelo en este caso). Por esta razón, el valor de los controles se toma desde el ModelState y no desde el modelo.

    En cuanto a las soluciones, la más natural me parece incluir en la vista el HTML del checkbox directamente: si no quieres mantener estado, ni generar validadores en cliente, ni nada parecido, realmente no hay ningún motivo para usar los helpers ofrecidos por el framework. Obviamente, también podrías crear tu propio helper para generar el HTML, sería lo mismo.

    Cualquier otra solución que implique servidor (exclusiones en el binding, o también eliminar del ModelState la entrada correspondiente al checkbox) sería llevar al controlador una problemática de cliente, imho, y no tiene mucho sentido.

    Un saludo!

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s