domingo, 31 de enero de 2010

Una idea para no redirigir tanto

Estaba escribiendo el esqueleto de unas acciones para gestionar el registro y confirmación de usuarios cuando me di cuenta de que hay situaciones en las que puede ser buena idea usar Controller->render() para dirigir al usuario a páginas que le informen sobre el resultado de sus acciones, en lugar de Controller->redirect() para hacer lo mismo.

Por ejemplo:

function register() {
if ($this->data) {
if ($this->User->register($this->data)) {
$this->render('registration_ok');
}
}
}


En esta acción, si el registro se hace correctamente, la acción muestra la vista 'registration_ok', que mostraría un mensaje explicando al usuario que su registro ha sido correcto y lo que debe o puede hacer a continuación.

En otro caso, se mostraría la vista por defecto 'register'.

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.

viernes, 8 de enero de 2010

Join con relaciones muchos a muchos

En la entrada anterior dejaba pendiente el tema de las relaciones muchos a muchos, aunque es probable que ya te hayas dado cuenta de como combinar tablas relacionadas de esta manera.

Este tipo de relaciones requiere una tabla intermedia (o join table) que nos permita asociar las parejas de registros. Las tablas izquierda y derecha se relacionan de uno a muchos con la join table. Por lo tanto tendremos que "unir" la tabla izquierda con la join table y ésta con la tabla derecha.

Etiquetar libros

Vamos a seguir con nuestro ejemplo y vamos a añadir una tabla tags a nuestro sistema para poder etiquetar cada libro con diversas palabras clave descriptoras. Por ejemplo, así:


CREATE TABLE `tags` (
  `id` int(11) NOT NULL auto_increment,
  `tag` varchar(200) default NULL,
  PRIMARY KEY  (`id`)
)

Y vamos a introducir algunos valores, para que la tabla quede así:


+----+-----------------------+
| id | tag                   |
+----+-----------------------+
|  1 | Novela                | 
|  2 | Lit. Castellana       | 
|  3 | Lit. Hispanoamericana | 
|  4 | Lit. Francesa         | 
|  5 | Poesia                | 
+----+-----------------------+
5 rows in set (0,00 sec)

Ahora creamos la join table:

create table books_tags (
books_id int(11) not null,
tags_id int(11) not null);

Y la poblamos para relacionar nuestros libros con sus tags correspondientes, nos quedaría algo así:

+----------+---------+
| books_id | tags_id |
+----------+---------+
|        1 |       1 | 
|        2 |       1 | 
|        3 |       1 | 
|        4 |       1 | 
|        1 |       2 | 
|        4 |       2 | 
|        2 |       3 | 
|        3 |       4 | 
+----------+---------+
8 rows in set (0,00 sec)

Ahora podemos empezar a trabajar combinando las tablas. En realidad es muy simple: definimos una cláusula JOIN con cada una de las tablas que queremos unir. Por ejemplo:

select * from books join books_tags join tags;

Esta query específicamente se puede abreviar usando ',' en vez de JOIN:

select * from books, books_tags, tags;

La petición anterior nos devolverá el producto cartesiano de las tres tablas (nada menos que 160 filas). Ya que las tablas están relacionadas podemos usar las sentencias ON sobre los campos de clave primaria y clave foránea:

select title, tag 
from books 
    join books_tags on books.id = books_tags.books_id 
    join tags on books_tags.tags_id = tags.id;

Es decir, unimos la tabla books con la books_tags cuando coinciden books.id y books_tags.books_id y ésta a su vez con la tabla tags, cuando coinciden books_tags.tags.id y tags.id.

El resultado (lo he restringido a los campos title y tag para que se vea más claro):

+---------------------+-----------------------+
| title               | tag                   |
+---------------------+-----------------------+
| El quijote          | Novela                | 
| El quijote          | Lit. Castellana       | 
| 100 a?os de soledad | Novela                | 
| 100 a?os de soledad | Lit. Hispanoamericana | 
| El Principito       | Novela                | 
| El Principito       | Lit. Francesa         | 
| Lazarillo de Tormes | Novela                | 
| Lazarillo de Tormes | Lit. Castellana       | 
+---------------------+-----------------------+
8 rows in set (0,00 sec)

¿Podríamos meter a los autores en esta petición? Vamos a verlo (añado también el campo author y una cláusula para ordenar los registros a fin de apreciar mejor los resultados):

select title, author, tag 
from books 
    join authors on books.author_id = authors.id 
    join books_tags on books.id = books_tags.books_id 
    join tags on books_tags.tags_id = tags.id 
order by title;

+---------------------+----------------+-----------------------+
| title               | author         | tag                   |
+---------------------+----------------+-----------------------+
| 100 a?os de soledad | Garc?a M?rquez | Lit. Hispanoamericana | 
| 100 a?os de soledad | Garc?a M?rquez | Novela                | 
| El Principito       | Saint_Exupery  | Novela                | 
| El Principito       | Saint_Exupery  | Lit. Francesa         | 
| El quijote          | Cervantes      | Novela                | 
| El quijote          | Cervantes      | Lit. Castellana       | 
+---------------------+----------------+-----------------------+
6 rows in set (0,00 sec)

Por supuesto, puedes usar los LEFT JOIN y RIGHT JOIN según tus necesidades, por ejemplo, para obtener el listado completo de libros y sus etiquetas:

select title, author, tag 
from books 
    left join authors on books.author_id = authors.id 
    join books_tags on books.id = books_tags.books_id 
    join tags on books_tags.tags_id = tags.id 
order by title;

En qué medida debes usar left o right join depende de si necesitas obtener todos los registros posibles de las tablas izquierda o derecha, o sólo aquellos que tienen datos en ambas tablas.

Ahora veamos cómo podemos buscar libros que correspondan a una etiqueta. Empezamos por la combinación de tablas y luego no tenemos más que indicar en WHERE qué etiquetas queremos seleccionar:

select title, author, tag 
from books 
    left join authors on books.author_id = authors.id 
    join books_tags on books.id = books_tags.books_id 
    join tags on books_tags.tags_id = tags.id 
where tags.tag = 'Novela'
order by title;

+---------------------+----------------+--------+
| title               | author         | tag    |
+---------------------+----------------+--------+
| 100 a?os de soledad | Garc?a M?rquez | Novela | 
| El Principito       | Saint_Exupery  | Novela | 
| El quijote          | Cervantes      | Novela | 
| Lazarillo de Tormes | NULL           | Novela | 
+---------------------+----------------+--------+
4 rows in set (0,00 sec)

También puede ser otra etiqueta, claro. Por ejemplo, qué libros tenemos de literatura castellana:

select title, author, tag 
from books 
    left join authors on books.author_id = authors.id 
    join books_tags on books.id = books_tags.books_id 
    join tags on books_tags.tags_id = tags.id 
where tags.tag = 'Lit. Castellana'
order by title;

Que nos dará este resultado:

+---------------------+-----------+-----------------+
| title               | author    | tag             |
+---------------------+-----------+-----------------+
| El quijote          | Cervantes | Lit. Castellana | 
| Lazarillo de Tormes | NULL      | Lit. Castellana | 
+---------------------+-----------+-----------------+
2 rows in set (0,00 sec)

Como puedes ver, aparte del pequeño lío que supone especificar las combinaciones de tablas a través de múltiples join el trabajo es bastante sencillo.

jueves, 7 de enero de 2010

Uniendo tablas con Join

En la entrada anterior dejé caer que explicaría un poco más a fondo los tipos de JOIN que se pueden hacer y qué diferencias hay entre ellos.

Una buena manera de entenderlo es practicando, por lo que es recomendable que crees algunas tablas sencillas y lances las querys como forma de ver en vivo los resultados de cada tipo de JOIN y así entender para qué casos te pueden servir. No hace falta que tengan muchos campos, ni muchos registros.

Un buen ejemplo puede ser una tabla de libros y una de autores, como las que siguen (en este ejemplo estoy usando MySQL):


CREATE TABLE `books` (
`id` int(11) NOT NULL auto_increment,
`title` varchar(200) default NULL,
`author_id` int(11) default NULL,
PRIMARY KEY (`id`)
)

CREATE TABLE `authors` (
`id` int(11) NOT NULL auto_increment,
`author` varchar(200) default NULL,
PRIMARY KEY (`id`)
)


Los datos para las tablas:




# Dump of table authors


# ------------------------------------------------------------

INSERT INTO `authors` (`id`,`author`) VALUES ('1','Cervantes');
INSERT INTO `authors` (`id`,`author`) VALUES ('2','García Márquez');
INSERT INTO `authors` (`id`,`author`) VALUES ('3','Saint_Exupery');


# Dump of table books
# ------------------------------------------------------------

INSERT INTO `books` (`id`,`title`,`author_id`) VALUES ('1','El quijote','1');
INSERT INTO `books` (`id`,`title`,`author_id`) VALUES ('2','100 años de soledad','2');

INSERT INTO `books` (`id`,`title`,`author_id`) VALUES ('3','El Principito','3');

Producto cartesiano

Para entender cómo funcionan los JOIN tenemos que empezar repasando un concepto que muchos aprendimos en la Primaria: el producto cartesiano. Ya sabes: dados dos conjuntos A y B, su producto cartesiano es otro conjunto C (A × B) formado por todos los pares ordenados en los que el primer elemento del par pertenece a A y el segundo elemento del par pertenece a B.

Así, JOIN es básicamente el producto cartesiano de las tablas, es decir, una nueva tabla en que cada registro combina un registro de la primera tabla con cada uno de los registros de la segunda tabla.

La query

SELECT * FROM books JOIN authors

nos dará como resultado todas las posibles combinaciones de books y authors (en nuestro caso 9 registros). Algo así:


+----+---------------------+-----------+----+----------------+
| id | title               | author_id | id | author         |
+----+---------------------+-----------+----+----------------+
|  1 | El quijote          |         1 |  1 | Cervantes      | 
|  2 | 100 a?os de soledad |         2 |  1 | Cervantes      | 
|  3 | El Principito       |         3 |  1 | Cervantes      | 
|  1 | El quijote          |         1 |  2 | Garc?a M?rquez | 
|  2 | 100 a?os de soledad |         2 |  2 | Garc?a M?rquez | 
|  3 | El Principito       |         3 |  2 | Garc?a M?rquez | 
|  1 | El quijote          |         1 |  3 | Saint_Exupery  | 
|  2 | 100 a?os de soledad |         2 |  3 | Saint_Exupery  | 
|  3 | El Principito       |         3 |  3 | Saint_Exupery  | 
+----+---------------------+-----------+----+----------------+
9 rows in set (0,03 sec)

Como puedes suponer, hacer este resultado no es muy útil para este tipo de datos, aunque hay muchos casos en que si que lo puede ser.

Por ejemplo, en un campeonato deportivo como una liga de fútbol tendremos una tabla equipos, que recoge el nombre de todos los equipos participantes.


+----+------------+
| id | equipo     |
+----+------------+
|  1 | Barcelona  | 
|  2 | Madrid     | 
|  3 | Celta      | 
|  4 | Villarreal | 
+----+------------+
4 rows in set (0,02 sec)


Pues bien, un JOIN de la tabla consigo misma, nos permitirá obtener todos los partidos del campeonato. Eso sí, tendremos que recurrir a los alias para evitar un error de MySQL.

SELECT * FROM equipos AS Local JOIN equipos as Visitante


+----+------------+----+------------+
| id | equipo     | id | equipo     |
+----+------------+----+------------+
|  1 | Barcelona  |  1 | Barcelona  | 
|  2 | Madrid     |  1 | Barcelona  | 
|  3 | Celta      |  1 | Barcelona  | 
|  4 | Villarreal |  1 | Barcelona  | 
|  1 | Barcelona  |  2 | Madrid     | 
|  2 | Madrid     |  2 | Madrid     | 
|  3 | Celta      |  2 | Madrid     | 
|  4 | Villarreal |  2 | Madrid     | 
|  1 | Barcelona  |  3 | Celta      | 
|  2 | Madrid     |  3 | Celta      | 
|  3 | Celta      |  3 | Celta      | 
|  4 | Villarreal |  3 | Celta      | 
|  1 | Barcelona  |  4 | Villarreal | 
|  2 | Madrid     |  4 | Villarreal | 
|  3 | Celta      |  4 | Villarreal | 
|  4 | Villarreal |  4 | Villarreal | 
+----+------------+----+------------+
16 rows in set (0,03 sec)


Con todo, esta query necesita alguna restricción para se perfecta, pues nos empareja cada equipo consigo misma, así que podemos añadir condiciones para eliminar esas parejas del resultado.

SELECT * FROM equipos AS Local JOIN equipos AS Visitante WHERE Local.id != Visitante.id


+----+------------+----+------------+
| id | equipo     | id | equipo     |
+----+------------+----+------------+
|  2 | Madrid     |  1 | Barcelona  | 
|  3 | Celta      |  1 | Barcelona  | 
|  4 | Villarreal |  1 | Barcelona  | 
|  1 | Barcelona  |  2 | Madrid     | 
|  3 | Celta      |  2 | Madrid     | 
|  4 | Villarreal |  2 | Madrid     | 
|  1 | Barcelona  |  3 | Celta      | 
|  2 | Madrid     |  3 | Celta      | 
|  4 | Villarreal |  3 | Celta      | 
|  1 | Barcelona  |  4 | Villarreal | 
|  2 | Madrid     |  4 | Villarreal | 
|  3 | Celta      |  4 | Villarreal | 
+----+------------+----+------------+
12 rows in set (0,00 sec)


INNER JOIN

Este tipo de JOINS que nos dan el producto cartesiano son del tipo INNER y los resultados que podemos obtener de ellas estan siempre dentro de ese producto cartesiano.

Como deciamos antes, este tipo de resultados no es muy útil en algunos casos. Volviendo a nuestro ejemplo de ibros y autores, la query nos empareja obras y autores de todas las maneras posibles, lo que no se corresponde con la realidad. Nuestro sistema tiene que tener más conocimiento del mundo y poder utilizarlo al hacer la combinacion de tablas.

Nuestra tabla books cuenta con el campo author_id, la clave foránea que nos indica qué autor corresponde a cada libro. ¿Qué papel puede jugar en la combinacion de tablas?

JOIN admite una cláusula ON para definir qué condiciones deben usarse para que dos registros se combinen. En nuestro ejemplo, el cambo books.author_id debe coincidir con el campo author.id y lo expresamos así:

SELECT * FROM books INNER JOIN authors ON books.author_id = authors.id


+----+---------------------+-----------+----+----------------+
| id | title               | author_id | id | author         |
+----+---------------------+-----------+----+----------------+
|  1 | El quijote          |         1 |  1 | Cervantes      | 
|  2 | 100 a?os de soledad |         2 |  2 | Garc?a M?rquez | 
|  3 | El Principito       |         3 |  3 | Saint_Exupery  | 
+----+---------------------+-----------+----+----------------+
3 rows in set (0,05 sec)


De este modo, la query nos devuelve los libros correctamente emparejados con sus autores.

Podemos añadir la cláusula WHERE para especificar condiciones que restrinjan la busqueda de datos y esta puede usar campos de las tablas combinadas. Así, podemos buscar un libro por el nombre de su autor, a pesar de que este dato no está en la tabla books.

SELECT * FROM books INNER JOIN authors ON books.author_id = authors.id WHERE authors.author = 'Cervantes'


+----+------------+-----------+----+-----------+
| id | title      | author_id | id | author    |
+----+------------+-----------+----+-----------+
|  1 | El quijote |         1 |  1 | Cervantes | 
+----+------------+-----------+----+-----------+
1 row in set (0,00 sec)


Cuando combinamos ON y WHERE nos puede surgir la duda de si sería mejor poner las condiciones en ON o en WHERE.

La regla práctica sería poner en ON las condiciones para decidir que registros deben emparejarse y en WHERE las condiciones para filtrar o restringir el resultado. La base de datos primero genera la tabla temporal y luego hace el filtrado.

LEFT JOIN

Puede ocurrir que tengamos datos en una tabla que no tengan un registro asociado en la otra. Por ejemplo, añadimos un nuevo libro a nuestra tabla books pero no sabemos su autor (o es anónimo).

Ahora si pedimos una lista de todos los libros registrados con sus autores con la query anterior veremos que no aparecen los libros que no tengan autor. ¡Vaya! En muchos casos este comportamiento no nos interesa, querríamos tener toda la lista de libros aunque no sepamos el autor.

Para eso utilizamos LEFT JOIN.

Este tipo de combinación toma todos los registros válidos de la primera tabla (o tabla izquierda/left) y los combina con los registros de la otra tabla (derecha). Si no hay ningún registro que se pueda combinar lo hace con uno nuevo cuyos campos están todos en NULL.

En nuestros datos actuales tenemos tres libros y conocemos a sus autores correspondientes, así que al pedir la información a la base de datos nos devolverá este resultado:

SELECT * FROM books LEFT JOIN authors ON books.author_id = authors.id


+----+---------------------+-----------+------+----------------+

| id | title               | author_id | id   | author         |
+----+---------------------+-----------+------+----------------+
|  1 | El quijote          |         1 |    1 | Cervantes      | 
|  2 | 100 a?os de soledad |         2 |    2 | Garc?a M?rquez | 
|  3 | El Principito       |         3 |    3 | Saint_Exupery  | 
+----+---------------------+-----------+------+----------------+
3 rows in set (0,00 sec)

Ahora introduciremos un nuevo libro del cual no conocemos el autor

INSERT INTO books (title) values ('Lazarillo de Tormes');

y repetimos la misma petición anterior. Este es el resultado:

+----+---------------------+-----------+------+----------------+
| id | title               | author_id | id   | author         |
+----+---------------------+-----------+------+----------------+
|  1 | El quijote          |         1 |    1 | Cervantes      | 
|  2 | 100 a?os de soledad |         2 |    2 | Garc?a M?rquez | 
|  3 | El Principito       |         3 |    3 | Saint_Exupery  | 
|  4 | Lazarillo de Tormes |      NULL | NULL | NULL           | 
+----+---------------------+-----------+------+----------------+
4 rows in set (0,00 sec)

La base de datos no encuentra un registro en authors que pueda emparejar con "Lazarillo de Tormes", pero al utilizar un LEFT JOIN también nos devuelve este libro, aunque deja los campos de authors sin definir. Para nosotros es útil porque de este modo podemos saber qué libros tenemos con independencia de si tenemos los datos de autor o no. La misma petición con un INNER JOIN nos dará el siguiente resultado:

SELECT * FROM books INNER JOIN authors ON books.author_id = authors.id;

+----+---------------------+-----------+----+----------------+
| id | title               | author_id | id | author         |
+----+---------------------+-----------+----+----------------+
|  1 | El quijote          |         1 |  1 | Cervantes      | 
|  2 | 100 a?os de soledad |         2 |  2 | Garc?a M?rquez | 
|  3 | El Principito       |         3 |  3 | Saint_Exupery  | 
+----+---------------------+-----------+----+----------------+
3 rows in set (0,00 sec)

Ves la diferencia, ¿verdad? Con el INNER JOIN sólo se devuelven resultados "dentro" del producto cartesiano (o dicho de otro modo, se devuelven pares de registros de ambas tablas). Es decir, se toman los registros de la tabla "izquierda" y se combinan con el registro correspondiente de la tabla "derecha" que cumpla las condiciones del ON y si no existe se ignora esa fila.

Con LEFT JOIN se podría decir que se toman todos los registros de la tabla izquierda relevantes (que cumplan las condiciones de WHERE si está presente) y se combinan con el registro correspondiente de la tabla "derecha" tanto si existe o no un registro en ella que cumpla las condiciones de ON.

RIGHT JOIN

Si entendiste bien el significado de LEFT JOIN seguro que eres capaz de deducir lo que significa RIGHT JOIN. Exacto: en este caso se parte de los registros de la tabla "derecha" y se busca si hay algún registro en la tabla "izquierda" que cumpla las condiciones en ON. En caso de no encontrarlo se ponen sus campos a NULL.

Para poder verlo en acción necesitamos añadir un author a nuestra tabla, que no tenga libros.

INSERT authors (author) values ('Quevedo')

A continuación ejecutamos una petición con RIGHT JOIN

SELECT * FROM books RIGHT JOIN authors ON books.author_id = authors.id;

El resultado es:

+------+---------------------+-----------+----+----------------+
| id   | title               | author_id | id | author         |
+------+---------------------+-----------+----+----------------+
|    1 | El quijote          |         1 |  1 | Cervantes      | 
|    2 | 100 a?os de soledad |         2 |  2 | Garc?a M?rquez | 
|    3 | El Principito       |         3 |  3 | Saint_Exupery  | 
| NULL | NULL                |      NULL |  4 | Quevedo        | 
+------+---------------------+-----------+----+----------------+
4 rows in set (0,00 sec)

Como era de esperar, el registro correspondiente al author "Quevedo" aparece recogido con los campos de la tabla books en null.

Y esto es todo de momento. En próximas entregas me dedicaré a las relaciones "muchos a muchos" y a hacer algunas consideraciones sobre la utilidad de los JOIN en CakePHP:






lunes, 4 de enero de 2010

Joins en CakePHP: buscar con registros relacionados

Voy a intentar escribir varias entradas sobre el uso de JOIN con CakePHP, porque me parece un tema interesante, demandado, y relativamente poco conocido en el framework.

Que es eso de JOIN

En SQL la cláusula JOIN nos permite hacer búsquedas en varias tablas de la base de datos que estén relacionadas entre sí.

Así, por ejemplo, si tenemos el típico caso de Post hasAndBelongsToMany Tag, usando JOINS podemos hacer una búsqueda de aquellos Post que hayan sido etiquetados con una Tag determinada.

Para entender el concepto de JOIN puedes imaginarte que tomas cada registro de la tabla "principal" y le añades los campos del registro relacionado en otra tabla, quedándote una especie de "supertabla", y en ella puedes realizar búsquedas sobre cualquier campo de ambas tablas.

Existen varios tipos de JOIN: LEFT, RIGHT e INNER, de los que hablaré en otra entrada, que nos permiten definir cómo queremos que se combinen los datos de las tablas. El resultado puede ser muy diferente en cada caso y el tipo elegido depende de nuestras finalidades.

La situación en CakePHP

Como bien sabes, CakePHP utiliza una sintaxis especial basada en arrays para crear las querys a la base de datos. La ventaja de este sistema es que nos permite cambiar con relativa facilidad del motor de base de datos. Este "lenguaje super-SQL" aparece documentado en el manual, pero no menciona los joins.

Por una parte, algunos tipos de asociaciones de Modelos de CakePHP ejecutan finds creando los JOIN necesarios y es posible hacer búsquedas de un modelo, usando campos de modelos relaciones, pero no es el caso de nuestras queridas hasAndBelongsToMany (creo que las hasMany tampoco).

Otro aspecto importante tiene que ver con la paginación, pues también hay complicaciones cuando se intenta paginar con relaciones habtm.

Hay algunos trucos para hacer estas búsquedas en relaciones habtm (como redefinir las asociaciones según convenga como hasMany y belongsTo de forma temporal, o directamente escribir el query a mano y ejecutarlo con Model->query()), pero este artículo de Nate Abele en Bakery sobre el uso de joins me abrió los ojos a una solución que para mí es mucho mejor: cómo forzar joins en CakePHP.

Joins en CakePHP, la respuesta corta

  1. Para hacer joins hay que usar el método Model->find($type, $options), donde $type es el tipo de find ('all', 'list', 'first', o uno personalizado) y $options es nuestro array con todos los detalles del query.
  2. Una de las claves de $options será justamente 'joins'
  3. Cada 'join' se representa mediante un array que tiene estas claves
    1. 'table': el nombre de la tabla
    2. 'alias': normalmente el nombre del modelo asociado a la tabla
    3. 'type': el tipo de join
    4. 'conditions': el array de condiciones que nos dice cómo se relacionan las tablas (básicamente las condiciones que pondríamos en la cláusula ON)
    5. 'foreignKey': es un boolean, aunque no tengo claro cuál es su función y en los ejemplos que he visto se pone a false (echando un vistazo a dbo_source me pregunto si ponerlo en true serviría para hacer algo de automagia con las asociaciones ya existentes, pero no lo tengo nada claro).
  4. Pones todas las definiciones de joins que necesites.
  5. Para hacer la búsqueda, añades condiciones en la clave 'conditions' como es habitual.
Un ejemplo

Lo siguiente es un fragmento del código que estoy usando en un método find personalizado de un proyecto en el que estoy trabajando. Espero que se entienda.

Se trata de un gestor de contenidos, en el que tengo los modelos Channel, Item y User de tal modo que Channel hasMany Item, y Channel hasAndBelongsToMany User.  Es decir, un Channel puede tener varios Item y cada Channel está asociado a varios Usuarios (que pueden estar asociados a varios Channel).

Ocurre que algunos Channel son "privados" para los User que están asociados a ellos. Quería un método find capaz de devolverme los Items de los Channel privados a los que un User tiene acceso. Para ello hago los siguientes joins

Item JOIN Channel para poder buscar los Channel marcados como privados (ver Channel.private en la clave 'conditions').

Channel JOIN ChannelsUser para poder restringir la búsqueda a los Channel asociados con el User (en este caso conozco el id del usuario, dato que paso a través de una clave propia 'user' y que puedo encontrar en la tabla channels_users, pero podría hacer un JOIN a user para restringir la búsqueda por algún otro campo de User). ChannelsUser es el joinModel de la asociación Channel habtm User, aunque no es necesario que lo definas expresamente (por supuesto, sí que necesitas tener la tabla channels_users par que funcione la relación).





/* Aquí definimos los join */

$extra['joins'] = array(
    array('table' => 'channels',
        'alias' => 'Channel',
        'type' => 'LEFT',
        'foreignKey' => FALSE,
        'conditions' => array(
            'Channel.id = Item.channel_id',
        )
    ),
    array('table' => 'channels_users',
        'alias' => 'ChannelsUser',
        'type' => 'LEFT',
        'foreignKey' => FALSE,
        'conditions' => array(
            'ChannelsUser.channel_id = Channel.id',
            'ChannelsUser.user_id' => $query['user']
        )
    )
);

/* Aquí están las condiciones, como puedes ver, ya podemos usar campos de los modelos relacionados, en este caso Channel */

$extra['conditions'] = array(
    'Channel.private' => 1,
    'Item.publish' => 1,
    'Item.pubDate <= curdate()',
    'or' => array(
        'Item.expiration is null',
        'Item.expiration > curdate()',
    )
);
$extra['order'] = array(
'Item.pubDate' => 'desc',
'Item.created' => 'desc'
);

/* Finalmente mezclo las opciones definidas aquí, con las que se hayan pasado como argumento a find */
$query = Set::merge($query, $extra);


Algunos detalles

Fíjate que los arrays que definen los joins no llevan ninguna clave, en todo caso podría ser numérica para definir el orden en que deben unirse las tablas (aunque lo defines simplemente escribiéndolas en el orden adecuado).

Otro detalle importante es la forma en que defino las 'conditions' dentro del join, en lugar de utilizar el formato típico de 'Modelo.campo' => 'OtroModelo.campo', lo hago 'Modelo.campo => OtroModelo.campo'. CakePHP parece asumir que el valor a la derecha siempre es un valor, no un campo (le pone comillas de valor, no de campo) y la condición nunca se cumple.

Si en lugar de un campo es un valor, el join funciona bien.

Una buena guía para sumergirse en HTML5

El libro de Mark Pilgrim Dive into HTML5 tiene una pinta estupenda para empezar a familiarizarse con el nuevo (e inacabado) estándar, y empezar a usarlo en la medida en que algunas de sus novedades ya están soportadas por los navegadores modernos.

Además, el autor hace varias recomendaciones prácticas acerca de cómo utilizar los nuevos elementos y características, dado a la vez soporte a navegadores que aún no las contemplan. Por ejemplo: cómo puedes empezar a utilizar ya los nuevos types del los input en los formularios, o cómo utilizar el elemento video sin dejar de lago el viejo explorer.

domingo, 3 de enero de 2010

Diseño web con HTML y CSS

El título suena a perogrullada, pero en 24 ways han publicado un gran artículo de Meagan Fisher acerca del proceso de diseño de webs usando código y relegando la costumbre de crear los bocetos mediante un editor de imágenes.

Lo cierto es que nunca fui capaz de diseñar una web mediante un editor de imágenes pues lo mío siempre ha sido lápiz y papel, aunque es una práctica muy común abrir el Photoshop o el programa equivalente y trabajar a partir de ahí.

El artículo hace hincapié en las capacidades de HTML y CSS 3, cuyas propiedades más avanzadas empiezan a estar soportadas por los navegadores más importantes, ya sean de la rama Mozilla (Firefox, Flock, Camino), de la Webkit (Safari, Chrome) o de Opera. Así que quitando ese que tú sabes, un navegador moderno permite jugar con propiedades como las sombras, opacidad, tipografía e incluso animaciones.

En ese sentido, es muy interesante visitar css3.info para empezar a familiarizarse con CSS3, conocer el soporte en cada familia de navegadores y cómo usar las nuevas propiedades.

En muchos casos estas propiedades están soportadas todavía como extensiones propias de cada navegador.

sábado, 2 de enero de 2010

Programar en inglés

Hace tiempo alguien preguntó en el grupo google de cakephp en español acerca de la costumbre de programar en inglés y hace un par de días volví a caer en este post de CakeBaker sobre el tema.

Bien, primero habría que explicar lo que entiendo por "programar en inglés". Para empezar, PHP, como tantos otros lenguajes de programación, es una especie de dialecto del inglés. Por programar en inglés, quiero decir usar este idioma para nombres de variables, clases, funciones, comentarios e incluso para los textos iniciales de la aplicación.

¿Y qué razones tendríamos para hacer esto?

Hay algunas bastante prácticas, como las siguientes:


  • Siendo PHP un lenguaje basado en el inglés, evitamos mezclar idiomas, lo que hace más fácil la lectura del código.
  • Es más fácil compartir código con otros programadores de todo el mundo. Y en consecuencia:
  • Es más fácil que otros programadores te puedan ayudar en foros y grupos de correo, etc.
  • En CakePHP, específicamente, te puedes evitar algunos problemas con el Inflector cuando intentas añadir reglas para dar soporte a español y otros idiomas y que interfieren con las establecidas por defecto para el inglés.
  • En bastantes casos, la jerga técnica en inglés es más completa, expresiva y precisa que en español, por lo que los comentarios serán mucho más informativos. 
Y, la más importante:
  • Programando en inglés, pareces mejor programador de lo que eres ;-)
echo "Happy new year!"