domingo, 17 de enero de 2010

Paginación y find personalizado. Un problema y ¿una solución?

Uno de los temas más habituales en los foros relacionados con CakePHP es el de la paginación de resultados. Surgen bastantes problemas cuando queremos ir un poco más allá de lo básico.

En mi caso, el problema que más fastidiado me tiene es la paginación con métodos find personalizados, ya que, por lo que he podido descubrir, cuando CakePHP calcula el número de registros totales no construye la petición correcta y, con frecuencia, ésta falla y o bien no devuelve la cuenta correcta o incluso el SQL falla porque no se incluyen las columnas adecuadas.

Esto es especialmente molesto cuando usas los métodos personalizados para hacer búsquedas a través de tablas relacionadas, por ejemplo, forzando los join necesarios, o añadiendo claves 'contain'.

Paginación y find personalizados

Es fácil paginar con métodos find personalizados. Basta indicarlo en la variable del controlador paginate, bajo la clave del modelo:

$this->paginate['Modelo'][0] = 'metodo';

Controller->paginate() hace una llamada a find('count') para obtener la cuenta total de registros y luego a find('el_metodo_que_sea') con los parámetros adecuados para obtener una página de datos. El problema es que cuando llama a find('count') éste recibe las condiciones que se hayan establecido en el controlador (a través de la variable paginate, por ejemplo) e ignora las modificaciones de la Query que se hacen en el método find personalizado.

Con frecuencia eso significa que la petición ya fracasa en la base de datos, por errores como "columna desconocida", y en el mejor de los casos nos hace una cuenta incorrecta, como por ejemplo, todos los registros de la tabla, cuando nosotros queremos seleccionar sólo los que tienen el campo "active" igual a 1 o cualquier otra condición que hayamos establecido en el método personalizado.

Por ejemplo, yo suelo utilizar mucho los joins en los métodos personalizados para evitar cargar el controlador con querys muy extensas. Por desgracia esto hace que la paginación "estándar" no sirva y haya que recurrir a otros métodos.

Métodos alternativos de contar

Generalmente se recomienda sobreescribir los métodos del modelo Model->paginate y Model->paginateCount, cosa que no me hace mucha gracia, porque precisamente una de las ventajas de los métodos find personalizados sería disponer de una batería de búsquedas típicas y tener que manejar cada caso en un método del modelo me da la impresión de que favorece un código confuso.

Puesto que personalizar los find es una "buena práctica", pienso que debería estar contemplado en Cake que se puedan usar en paginate con todas las consecuencias.

Solución 1

Mariano Iglesias propuso hace tiempo una solución, que no está mal. Consiste en guardar en un array los diferentes conjuntos de opciones para cada tipo de find y sobreescribir el método genérico find en AppModel. En este método se harían dos cosas:

En primer lugar, cuando se realiza un find personalizado, se mezclan mediante Set::merge las opciones predefinidas y las que se pasan en la llamada. Luego se ejecuta un find('all') con las nuevas opciones.

Cuando la llamada se hace desde Controller->paginate, también se efectúa una llamada a find('count'). La parte que nos interesa es que en esta llamada también se pasa el nombre del método personalizado, de modo, que se consulta el array de opciones y se pasa la información a find('count') para que pueda construir el SQL correcto.

Lo mejor es que eches un vistazo al código de Mariano.

Solución 2

Buceando en el código llegué a la conclusión de que hay otra vía "prometedora".  La verdad es que había leído el artículo de Mariano Iglesias por encima y no me había parado a estudiarlo a fondo, lo que a lo mejor me hubiese ahorrado bastante trabajo, ya que en realidad el enfoque es muy parecido: se trata de asegurarse de que find('count') recibe las opciones correctas para construir su query.

El método que pide la cuenta de registros a la base de datos es Model->_findCount(), el cual toma recibe la query solicitada y la convierte en una query de recuento.

Éste método "sabe" si la petición va a ir seguida de una llamada a otro método find y de cuál (en las opciones recibe una clave 'type' que contiene esa información). El caso es que actualmente, _findCount() no hace nada con ese dato, por lo tanto, si nuestro método personalizado modifica la query de un modo u otro, no lo tiene en cuenta.

Por otro lado, uno de los sistemas para crear find personalizados se basa en métodos con dos pasadas. En la primera pasada se modifica la query, y devuelve la modificada, y en la segunda se procesan los resultados.

Así que podemos saber cuál es la query que se ejecutará "preguntándole" al método adecuado por ella. En principio, esto se puede hacer desde el _findCount. Por lo que con unas pocas líneas se consigue que éste tome exactamente la query adecuada.

Y puedes ver el método _findCount() modificado en mi AppModel.

Aparte de eso, envié la idea para ver si se puede hacer algo en el código del core. Puedes ver la discusión en este ticket.

Problemas

Ambas soluciones tienen algunos problemas.

El principal de ellos es que puede ocurrir que el find personalizado haga alguna cosa más que modificar la query (es la objeción que pone Mark Story en la discusión del ticket que puse) y puesto que llamaríamos al método dos veces (en la misma fase) podemos tener resultados indeseados.

1 comentario:

jordicakephp dijo...

¡Hola!

Tengo problemas para enviar este comentario. Disculpad si lo recibís dos veces...

Yo me he acostumbrado a definir mis métodos personalizados para hacer paginaciones porque todo lo out-of-the-box, que dicen, no se adapta siempre al 100% (a veces) a lo que necesito. Lo que hago, pues, que no sé si muy bien, es definir mis métodos de tipo paginate, que siempre, por convención propìa, devuelven algo parecido a lo siguiente:

...
$pagination['nRecords'] = $nRecords;
$pagination['nPages'] = ceil($nRecords['total']/10);
$pagination['records'] = $records;
return $pagination;

O sea: número de registros encontrados, número de páginas totales y número de registros existentes. Todo esto al menos, porque siempre hay búsquedas que devuelven más información. Yendo algo más lejos, puede uno montar una jerarquía polimórfica de modelos para que funciene sólo en determinadas situaciones...

Estos métodos los llama, claro, el controlador, que siempre recibe un URL con la forma /////. Por ejemplo: /articulos/index/es-es/2008-10-02/5. Estos URI son amigables para el usuario y comp rensibles para el programador. Su semántica es, a mi juicio, consistente y buena. O sea, que esto debería estar bien y podemos entenderlo, por tanto, como una convención. Así, el controlador puede llamar al modelo con los params que necesita para que la vista reciba la variable $pagination en el formato-por-propia convención. Pure synthesis: el modelo hace todo el trabajo y el controlador actúa como un pegamento.

Esto requiere, eso sí, un paginador propio que procese la variable que devuelve el modelo: $pagination, hemos dicho, que contiene al menos las variables que hemos presentado antes. Como "in my convention world" siempre hay un numerito en las URL que contiene la página que desea uno visualizar, conseguí programar un element paginador con menos líneas PHP que población activa en España. Unas 10 líneas. El cálculo estrella que hace ese element para que funcione es...
$currentTen = (ceil($this->passedArgs[3]/10)*10)-10;

Dejemos al lector que procese esta fórmula y ate los cabos que puede encontrar aquí.

En resumen.

1. Definir métodos paginadores propios en el modelo que devuelvan a la vista un array más o menos congruente, consistente, que resuma bien el resultado de la consulta. El controlador es aquí un puro pegamento.

2. Programar un paginador propio que trabaje con el array de datos que el modelo prepara.

Ventajas:

Mejora en la escalabilidad de la aplicación, en el mantenimiento, en las refactorizaciones, en la comprensión por parte de otros programadores, etc.

Inconvenientes:

No sé si esto que digo está realmente muy bien o si estoy absolutamente equivocado.