Ver índice de contenidos del libro

8.1. Creando un comportamiento de Doctrine

En esta sección se explica cómo crear un nuevo comportamiento haciendo uso de Doctrine 1.2. El ejemplo utilizado permitirá mantener una cache del número de relaciones de un registro para no tener que hacer esa consulta todo el rato.

La funcionalidad es realmente simple: en todas las relaciones en las que quieras controlar su número, el comportamiento añade una columna a su modelo para almacenar un contador.

8.1.1. El esquema

Inicialmente se va a utilizar el siguiente esquema. Más adelante se modifica para añadir la definición actAs del comportamiento que se va a crear:

# config/doctrine/schema.yml
Thread:
  columns:
    title:
      type: string(255)
      notnull: true

Post:
  columns:
    thread_id:
      type: integer
      notnull: true
    body:
      type: clob
      notnull: true
  relations:
    Thread:
      onDelete: CASCADE
      foreignAlias: Posts

Seguidamente se construyen todas las clases del esquema:

$ php symfony doctrine:build --all

8.1.2. La plantilla

En primer lugar se crea la clase básica de tipo Doctrine_Template que será la responsable de añadir las columnas al modelo que guardará los contadores.

Añade la siguiente clase dentro de cualquier directorio lib/ del proyecto para que symfony pueda cargarla de forma automática:

// lib/count_cache/CountCache.class.php
class CountCache extends Doctrine_Template
{
  public function setTableDefinition()
  {
  }
 
  public function setUp()
  {
  }
}

A continuación se modifica el modelo Post para añadir el comportamiento CountCache mediante actAs:

# config/doctrine/schema.yml
Post:
  actAs:
    CountCache: ~
  # ...

Ahora que el modelo Post hace uso del comportamiento CountCache, su funcionamiento es el siguiente: cuando se instancia la información de mapeo de un modelo, se invocan los métodos setTableDefinition() y setUp() de todos sus comportamientos asociados. Esto es lo mismo que sucede con la clase BasePost en lib/model/doctrine/base/BasePost.class.php. Esta característica permite añadir elementos de todo tipo a un modelo, como columnas, relaciones, eventos, etc.

Ahora que está más claro su funcionamiento interno, se añade toda la lógica interna del comportamiento CountCache:

class CountCache extends Doctrine_Template
{
  protected $_options = array(
    'relations' => array()
  );
 
  public function setTableDefinition()
  {
    foreach ($this->_options['relations'] as $relation => $options)
    {
      // si no se dispone del nombre de la columna, se crea
      if (!isset($options['columnName']))
      {
        $this->_options['relations'][$relation]['columnName'] = 'num_'.Doctrine_Inflector::tableize($relation);
      }
 
      // añadir la columna al modelo relacionado
      $columnName = $this->_options['relations'][$relation]['columnName'];
      $relatedTable = $this->_table->getRelation($relation)->getTable();
      $this->_options['relations'][$relation]['className'] = $relatedTable->getOption('name');
      $relatedTable->setColumn($columnName, 'integer', null, array('default' => 0));
    }
  }
}

El código superior añade columnas para mantener los contadores de los modelos relacionados. Por tanto, en este caso se añade el comportamiento en el modelo Post para su relación Thread. De esta forma, el número de posts de cualquier Thread se almacena en una columna llamada num_posts. A continuación, modifica el esquema YAML para definir las opciones adicionales del comportamiento:

# ...

Post:
  actAs:
    CountCache:
      relations:
        Thread:
          columnName: num_posts
          foreignAlias: Posts
  # ...

Ahora el modelo Thread dispone de una columna llamada num_posts y que guardará de forma actualizada el número de posts que tiene cada hilo de discusión.

8.1.3. El event listener

El siguiente paso consiste en crear un event listener de registro que será el que se encargue de mantener actualizado el contador cuando se creen nuevos registros y cuando se borren registros de forma individual o en bloque.

class CountCache extends Doctrine_Template
{
  // ...
 
  public function setTableDefinition()
  {
    // ...
 
    $this->addListener(new CountCacheListener($this->_options));
  }
}

Antes de continuar es necesario definir la clase CountCacheListener que extiende la clase Doctrine_Record_Listener y que acepta un array de opciones que simplemente se pasan al listener de la plantilla:

// lib/model/count_cache/CountCacheListener.class.php
 
class CountCacheListener extends Doctrine_Record_Listener
{
  protected $_options;
 
  public function __construct(array $options)
  {
    $this->_options = $options;
  }
}

Para mantener los contadores actualizados es preciso utilizar los siguientes eventos:

  • postInsert(): incrementa el contador cuando se inserta un nuevo objeto
  • postDelete(): decrementa el contador cuando se borra un objeto
  • preDqlDelete(): decrementa el contador cuando se borrar varios objetos mediante un borrado DQL.

En primer lugar se define el método postInsert():

class CountCacheListener extends Doctrine_Record_Listener
{
  // ...
 
  public function postInsert(Doctrine_Event $event)
  {
    $invoker = $event->getInvoker();
    foreach ($this->_options['relations'] as $relation => $options)
    {
      $table = Doctrine::getTable($options['className']);
      $relation = $table->getRelation($options['foreignAlias']);
 
      $table
        ->createQuery()
        ->update()
        ->set($options['columnName'], $options['columnName'].' + 1')
        ->where($relation['local'].' = ?', $invoker->$relation['foreign'])
        ->execute();
    }
  }
}

El código anterior incrementa en una unidad, mediante una consulta de tipo DQL UPDATE, los contadores de todas las relaciones configuradas cada vez que se inserta un nuevo objeto, como por ejemplo el siguiente:

$post = new Post();
$post->thread_id = 1;
$post->body = 'contenido del post';
$post->save();

El Thread cuyo id valga 1 incrementará en una unidad el valor de su columna num_posts.

Ahora que los contadores ya se incrementan al insertar nuevos objetos, es necesario decrementarlos cuando se borre algún objeto. Para ello se define el siguiente método postDelete():

class CountCacheListener extends Doctrine_Record_Listener
{
  // ...
 
  public function postDelete(Doctrine_Event $event)
  {
    $invoker = $event->getInvoker();
    foreach ($this->_options['relations'] as $relation => $options)
    {
      $table = Doctrine::getTable($options['className']);
      $relation = $table->getRelation($options['foreignAlias']);
 
      $table
        ->createQuery()
        ->update()
        ->set($options['columnName'], $options['columnName'].' - 1')
        ->where($relation['local'].' = ?', $invoker->$relation['foreign'])
        ->execute();
    }
  }
}

El método postDelete() superior es casi idéntico al método postInsert(), siendo la única diferencia que en este caso el valor de la columna num_posts se decrementa en una unidad. Si ahora se borra el registro creado anteriormente, el contador se actualiza correctamente:

$post->delete();

La última parte del comportamiento debe encargarse de los borrados masivos realizados con una consulta de tipo DQL. La solución consiste en crear un método preDqlDelete():

class CountCacheListener extends Doctrine_Record_Listener
{
  // ...
 
  public function preDqlDelete(Doctrine_Event $event)
  {
    foreach ($this->_options['relations'] as $relation => $options)
    {
      $table = Doctrine::getTable($options['className']);
      $relation = $table->getRelation($options['foreignAlias']);
 
      $q = clone $event->getQuery();
      $q->select($relation['foreign']);
      $ids = $q->execute(array(), Doctrine::HYDRATE_NONE);
 
      foreach ($ids as $id)
      {
        $id = $id[0];
 
        $table
          ->createQuery()
          ->update()
          ->set($options['columnName'], $options['columnName'].' - 1')
          ->where($relation['local'].' = ?', $id)
          ->execute();
      }
    }
  }
}

El código anterior clona la consulta de tipo DQL DELETE y la transforma en una consulta SELECT que permite obtener los ID de los registros que se van a borrar, de forma que se pueda actualizar correctamente el contador.

Ahora ya es posible manejar consultas como la siguiente actualizando de forma correcta el valor de los contadores:

Doctrine::getTable('Post')
  ->createQuery()
  ->delete()
  ->where('id = ?', 1)
  ->execute();

El valor de los contadores se actualiza correctamente incluso cuando se borran varios registros a la vez:

Doctrine::getTable('Post')
  ->createQuery()
  ->delete()
  ->where('body LIKE ?', '%cool%')
  ->execute();

Nota Para invocar el método preDqlDelete() es necesario activar un atributo. El motivo es que los callbacks de DQL están desactivados por defecto porque penalizan ligeramente el rendimiento. Por tanto, para utilizarlos es necesario activarlos:

$manager->setAttribute(Doctrine_Core::ATTR_USE_DQL_CALLBACKS, true);

¡Y eso es todo! El nuevo comportamiento ya está terminado. Lo último que falta por hacer es añadir algunas pruebas unitarias.

8.1.4. Pruebas

Ahora que el código ya está completado, se va a probar con los siguientes datos de prueba:

# data/fixtures/data.yml

Thread:
  thread1:
    title: Test Thread
    Posts:
      post1:
        body: This is the body of my test thread
      post2:
        body: This is really cool
      post3:
        body: Ya it is pretty cool

A continuación se ejecuta la siguiente tarea para volver a crear todas las clases y para cargar todos los datos de prueba:

$ php symfony doctrine:build --all --and-load

Después de volver a crear y cargar todo, se realiza la siguiente prueba para comprobar que los contadores se actualizan correctamente:

$ php symfony doctrine:dql "FROM Thread t, t.Posts p"
doctrine - executing: "FROM Thread t, t.Posts p" ()
doctrine -   id: '1'
doctrine -   title: 'Test Thread'
doctrine -   num_posts: '3'
doctrine -   Posts:
doctrine -     -
doctrine -       id: '1'
doctrine -       thread_id: '1'
doctrine -       body: 'This is the body of my test thread'
doctrine -     -
doctrine -       id: '2'
doctrine -       thread_id: '1'
doctrine -       body: 'This is really cool'
doctrine -     -
doctrine -       id: '3'
doctrine -       thread_id: '1'
doctrine -       body: 'Ya it is pretty cool'

El valor de la columna num_posts del modelo Thread vale tres. Si se borra un post mediante el siguiente comando, el contador debe decrementarse:

$post = Doctrine_Core::getTable('Post')->find(1);
$post->delete();

Como se puede comprobar, el registro se ha borrado y el contador se ha actualizado.

$ php symfony doctrine:dql "FROM Thread t, t.Posts p"
doctrine - executing: "FROM Thread t, t.Posts p" ()
doctrine -   id: '1'
doctrine -   title: 'Test Thread'
doctrine -   num_posts: '2'
doctrine -   Posts:
doctrine -     -
doctrine -       id: '2'
doctrine -       thread_id: '1'
doctrine -       body: 'This is really cool'
doctrine -     -
doctrine -       id: '3'
doctrine -       thread_id: '1'
doctrine -       body: 'Ya it is pretty cool'

También funciona correctamente cuando se borran los otros dos registros restantes mediante una consulta de tipo DQL.

Doctrine_Core::getTable('Post')
  ->createQuery()
  ->delete()
  ->where('body LIKE ?', '%cool%')
  ->execute();

Ahora que se han borrado todos los posts relacionados, el valor de la columna num_posts debería ser cero.

$ php symfony doctrine:dql "FROM Thread t, t.Posts p"
doctrine - executing: "FROM Thread t, t.Posts p" ()
doctrine -   id: '1'
doctrine -   title: 'Test Thread'
doctrine -   num_posts: '0'
doctrine -   Posts: {  }

¡Y eso es todo! Confiamos que este artículo te haya sido útil tanto por haber aprendido a crear comportamientos como por el propio comportamiento creado.