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.

3 comentarios en “De cómo en AspNetMVC prevalece QueryString sobre el propio Modelo”

  1. Todo el mundo que se inicia en MVC tiene la misma pregunta. Ese comportamiento es es esperado y se ha introducido para mantener los valores que se postean.

    Como es así por diseño, no puede considerarse bug sino una feature que de hecho es muy util la mayoría de las veces.

    Lo que prevalece no es el querystring sino el Modelstate! Y la regla es la siguiente: si tengo un valor en el modelstate uso ese, de lo contrario uso el del modelo. Por eso, si quieres que el valor final sea el de tu checkbok en falso solo tienes que eliminar ese valor del modelstate y listo.

    Para verificarlo podés probar con ModelState.Clear() y verás que el valor que se utilizará será el de tu modelo.

    Saludos.

  2. Buenas Pablo, por twitter no terminé de entender tu duda, creía que el problema lo tenías en los bindings al recibir los datos, de ahí mi contestación con los ValueProviders!! 😀

    Bueno, Lucas se me ha avanzado, pero voy a completar su respuesta!

    La pregunta sería… porque se comportan así los helpers? 😉

    La respuesta es bien simple: para permitir valores ERRÓNEOS ser preservados entre dos llamadas a la misma página.
    Pablo, imagina que tienes un formulario en el cual debes enviar determinados datos, p.ej. un textbox que tienes enlazado a un Datetime. Imagina que el usuario entra “foo” como fecha, algo a todas luces inválido. Eso provocará un error en el binding: tu modelo NO tendrá el valor “foo” en el datetime (es imposible) pero en ModelState si habrá “foo” asignado a este campo… cuando renderices de nuevo la vista, gracias a que se usa Modelstate ANTES que el modelo, el usuario verá el texto “foo” en el textbox (además del textbox en rojo), teniendo bien claro cual es el campo que ha causado el error.

    Este es el motivo por el cual los helpers de EDICIÓN, usan ModelState antes que el Modelo. Los de visualización por su parte si usan el Modelo directamente.

    Un saludo!

  3. El culpable entonces es el silencioso ModelState, que hace su trabajo sin darse a notar. Gracias por vuestros comentarios aclaratorios Lucas, Edu y José María (este en http://xamlsorpresa.wordpress.com/2011/12/16/de-cmo-en-aspnetmvc-prevalece-querystring-sobre-el-propio-modelo/#comments)

    El uso y prevalencia de ModelState me sigue pareciendo poco natural, aunque no puedo negar su utilidad para el caso planteado por Edu y por José María: si no se puede almacenar el valor en el modelo por una incompatibilidad de tipos, al menos que el valor no se pierda y se lo podamos devolver al usuario. El problema es que así y todo no deja de “ensuciar” la limpieza de MVC, no es un comportamiento natural y en algunos casos su prevalencia supone más un engorro que una ayuda.

    Aunque ya lo resolví pasando de CheckBoxFor y usando un elemento input en bruto, me quedo a futuro con el consejo de Lucas: llamar a ModelState.Clear() en el controlador. Me parece esta la solución más elegante.

Deja un comentario

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