Mostrando entradas con la etiqueta habtm. Mostrar todas las entradas
Mostrando entradas con la etiqueta habtm. Mostrar todas las entradas

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.

jueves, 28 de agosto de 2008

Paginación con relaciones HABTM

Bueno, el artículo de Cakebaker que puedes leer pinchando en el título o aquí, me ha servido para resolver un problema que me lleva ocupando todo el día. En fin, Daniel Hofstetter es un capo en este de CakePHP.

La cosa es más o menos así:

Tengo un model Blog y un model User, de forma que Blog hasAndBelongsToMany User. Es decir, que un blog puede estar asociado a muchos usuarios y cada uno de estos estar asociado a varios blogs.

Yo quería identificar que algunos de esos usuarios son Administradores del Blog y otros son sólo Escritores. La idea es que cuando un usuario haga login y acceda a administrar sus blogs salgan sólo aquellos de los que es administrador (como es lógico, ¿no?).

Bien, para esto tenemos que "modelizar" la relación, o lo que es lo mismo, utilizar la join table como un modelo más. Es decir, si añadimos unos campos a la join table blogs_users podemos describir características de la relación entre cada usuario y cada blog. Por ejemplo, un campo admin, para indicar si el usuario es administrador o no del blog relacionado.

Por otro lado, y según el artículo de Cakebaker, también tenemos que modelizar la relación para conseguir la paginación de los resultados.

¿Cómo se hace esto?

Bien. Para crear el modelo tenemos que añadir un archivo app/models/blogs_user.php que contenga la definición básica (observa el nombre del modelo: viene del nombre de la tabla blogs_users, es CamelCased, empieza en mayúsculas y var singular):


En las definiciones de Blog y User tenemos que añadir la clave 'with' para indicar que las relaciones se harán mediante ese modelo. Así en app/models/blog.php

var $hasAndBelongsToMany = array(
'User' =>; array(
'className' => 'User',
'joinTable' => 'blogs_users',
'foreignKey' => 'blog_id',
'associationForeignKey' => 'user_id',
'unique' => true,
'with' => 'BlogsUser'
)
);


Y en app/models/user.php tres cuartos de lo mismo. Añade la clave 'with'.

Con esto ya tenemos la base del problema resuelto. Para hacer la paginación lo que tenemos que especificar en Controller::paginate() es que la haga usando la relación modelizada:

$this->paginate('BlogsUser')


He aquí un ejemplo, que estaría en app/controllers/blogs_controller.php:

function admin_index() {
if ($this->Auth->user('root') == 1) {
$this->set('blogs', $this->paginate());
} else {
$options = array(
'BlogsUser.admin' => 1,
'BlogsUser.user_id' => $this->Auth->user('id')
);
$this->set('blogs', $this->paginate('BlogsUser', $options) );
}
}

Hay que decir, antes de nada, que esta aplicación usa el Auth Componente para autentificar usuarios, de modo que podemos saber qué usuario está actualmente conectado llamando a $this->Auth->user().

En este caso, primero comprobamos si el usuario autentificado se trata de un root, en cuyo caso simplemente buscamos todos los Blogs sin ninguna condición en especial. 

Si no es así, establecemos las condiciones para localizar los blogs administrados por el usuario autentificado, datos que están en BlogsUser. Luego hacemos el paginate indicando que use el modelo BlogsUser. Podemos añadir las condiciones que deseemos en cualquiera de los tres modelos implicados.

lunes, 21 de enero de 2008

Buscando en relaciones habtm

Supongamos un típico ejemplo de CMS con su modelo de Post y Tags, los cuales se asocian entre sí mediante una relación de tipo HasAndBelongsToMany (o muchos a muchos en otras terminologías).

Este tipo de asociaciones require una tabla íntermedia de unión (join table) que siguiendo las convenciones de CakePHP nombraríamos en este caso posts_tags. Ya sabes.

Post habtm Tag a través de posts_tags

Cuando hacemos un búsqueda en Post, CakePHP automágicamente incluye en los resultados las Tag asociados a cada Post. Pero, ¿podemos buscar los Post que estén asociados a una o varias Tag además de otras condiciones?

La respuesta es que directamente no podemos hacerlo ya que en las relaciones habtm no se hace el "join" automático de las tablas (al contrario de lo que ocurre en las relaciones hasMany o belongsTo).

Crear una query en SQL para utilizar con Model::query() es una solución, pero tiene el inconveniente de que perdemos algunas de las facilidades propias de CakePHP (básicamente los callbacks como afterFind o beforeFind) y otras ventajas.

En CakePHP 1.2 es posible hacer esto de una manera bastante sencilla aunque require "modelizar" la tabla intermedia. Primero, tenemos que crear un modelo para la tabla posts_tags y especificar sus relaciones con las tablas Post y Tag. Siguiendo las convenciones de CakePHP nos queda así:

<?php

class PostsTag extends AppModel {
var $belongsTo = array(
'Tag',
'Post');
}

?>


En el modelo Post tenemos que especificar que la relación con Tag se va a hacer "con" (with) ese modelo intermedio.

var $hasAndBelongsToMany = array (
'Tag' => array(
'className' => 'Tag',
'with' => 'PostsTag'
)
);


Hasta aquí el trabajo preparatorio. Ahora veamos un ejemplo de su uso. En el model Post tengo un método "publicados" que me devuelve los Post cuya fecha de publicación es anterior a la actual, cuyo flag de publicar está a 1 y que están etiquetados con una tag que le pasamos al método.

function publicados ($tag = false) {
$now = date ('Y-m-d');
$conditions = array (
'Tag.tag' => $tag,
'Post.publicar' => '1',
'Post.publicacion' => "<= $now"
);
return $this->PostsTag->find('all', array('conditions' => $conditions));
}


Espero que se entienda. Las condiciones no deberían tener mucha dificultad. Toda la clave es el find que hacemos sobre el modelo intermedio (PostsTag) asociado a Post, no directamente a Post. (Nota: he suprimido la parte del código que gestiona lo que hay que hacer si no se pasa ninguna $tag).

Algunas referencias que me sirvieron de punto de partida:

Modelizing HABTM join tables in CakePHP 1.2: with and auto-with models de Mariano Iglesias

Modeling relationships in CakePHP (faking Rails’ ThroughAssociation) de Felix Geisendörfer