Caution: You are browsing the legacy symfony 1.x part of this website.
Cover of the book Symfony 5: The Fast Track

Symfony 5: The Fast Track is the best book to learn modern Symfony development, from zero to production. +300 pages showcasing Symfony with Docker, APIs, queues & async tasks, Webpack, SPAs, etc.

Uso avanzato di Doctrine

Di Jonathan H. Wage

Scrivere un comportamento per Doctrine

In questa sezione verrà mostrato come è possibile scrivere un comportamento utilizzando Doctrine 1.2. Verrà creato un esempio che consenta di mantenere facilmente in cache un contatore di relazioni, in modo tale che non sia necessario tutte le volte fare la query per ottenere il conteggio.

La funzionalità è molto semplice. Si vuole gestire un contatore per tutte le relazioni e per fare ciò il comportamento aggiungerà al modello una colonna dove memorizzare il conteggio corrente.

Lo schema

Questo è lo schema che si userà per iniziare. Successivamente verrà modificato, aggiungendo la definizione actAs per il comportamento che si sta per scrivere:

# 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

Ora si può creare tutto il necessario per tale schema:

$ php symfony doctrine:build --all

Il template

In primo luogo, è necessario scrivere una classe figlia Doctrine_Template, che sarà responsabile di aggiungere le colonne al modello che memorizzerà i conteggi.

Si può inserire questo codice in una delle cartelle lib/ del progetto e symfony sarà in grado di caricarlo automaticamente

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

Modificare il modello Post aggiungendo ActAs, con il comportamento CountCache:

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

Ora che il modello Post utilizza il comportamento CountCache, cerchiamo di capire che cosa succede con il suo utilizzo.

Quando le informazioni di mapping per un modello sono istanziate, eventuali comportamenti collegati ottengono l'invocazione dei metodi setTableDefinition() e setUp(), proprio come si ha nella classe BasePost in lib/model/doctrine/base/BasePost.class.php. Questo permette di aggiungere cose a qualsiasi modello in uno stile plug'n'play. Queste "cose" possono essere colonne, relazioni, ascoltatori di eventi, ecc.

Ora che si è compreso un po' di più su quello che sta succedendo, bisogna fare in modo che il comportamento CountCache faccia qualcosa:

class CountCache extends Doctrine_Template
{
  protected $_options = array(
    'relations' => array()
  );
 
  public function setTableDefinition()
  {
    foreach ($this->_options['relations'] as $relation => $options)
    {
      // costruisce il nome della colonna, se non fornito
      if (!isset($options['columnName']))
      {
        $this->_options['relations'][$relation]['columnName'] = 'num_'.Doctrine_Inflector::tableize($relation);
      }
 
      // aggiunge la colonna al modello relativo
      $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));
    }
  }
}

Il codice qui sopra aggiunge le colonne per mantenere il conteggio sul modello collegato. Nel nostro caso si sta aggiungendo il comportamento al modello Post per la relazione Thread. Si vuole memorizzare il numero di post che ha ogni data istanza di Thread, in una colonna di nome num_posts. Ora è possibile modificare lo schema YAML per definire le opzioni extra del comportamento:

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

Ora il modello Thread ha una colonna num_posts, che verrà tenuta aggiornata con il numero di post che ha ogni thread.

L'ascoltatore di eventi

Il passo successivo per costruire il comportamento è quello di scrivere un ascoltatore per registrare gli eventi, che sarà incaricato di tenere il conteggio aggiornato quando si inserisce un nuovo record, si cancella un record o si cancellano record con dei batch DQL:

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

Prima di andare avanti, bisogna definire la classe CountCacheListener, che estende Doctrine_Record_Listener. Essa accetta un array di opzioni, che sono semplicemente inoltrate all'ascoltatore dal template:

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

Ora è necessario utilizzare i seguenti eventi al fine di mantenere il conteggio aggiornato:

  • postInsert(): Incrementa il conteggio quando viene inserito un nuovo oggetto;

  • postDelete(): Decrementa il conteggio quando viene cancellato un oggetto;

  • postDqlUpdate(): Decrementa il conteggio quando i record sono cancellati per mezzo di una delete DQL.

In primo luogo, definire il metodo 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();
    }
  }
}

Il codice sopra incrementerà di uno i conteggi per tutte le relazioni configurate mediante una query DQL UPDATE, quando un nuovo oggetto come il seguente è inserito:

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

Il Thread con un id di 1 otterrà che la colonna num_posts venga incrementata di 1.

Ora che i contatori sono incrementati all'inserimento di nuovi oggetti, è necessario gestire il caso in cui gli oggetti vengono cancellati e decrementare i contatori. Verrà implementato con il metodo 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();
    }
  }
}

Il metodo postDelete() di cui sopra è quasi identico a postInsert(), l'unica differenza è che viene decrementata la colonna num_posts di 1 invece di incrementarla. Gestisce il seguente codice, nel caso si volesse cancellare il record $post che è stato salvato in precedenza:

$post->delete();

L'ultimo pezzo del puzzle è quello di gestire il caso in cui i record vengono cancellati usando un update DQL. Si può risolverlo utilizzando il metodo 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();
      }
    }
  }
}

Il codice sopra clona la query DQL DELETE e la trasforma in una SELECT, che permette di recuperare gli ID che verranno cancellati, in modo che sia possibile aggiornare i contatori dei record che sono stati cancellati.

Ora si tiene conto del seguente scenario, decrementando i contatori:

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

E anche se si dovessero cancellare record multipli, i contatori sarebbero decrementati correttamente:

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

note

Perché il metodo preDqlDelete() sia invocato, è necessario abilitare un attributo. Le callback DQL per impostazione predefinita sono a off a causa del loro (piccolo) costo extra. Quindi per utilizzarle è necessario abilitarle.

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

Questo è tutto! Il comportamento è terminato. Non rimane che testarlo un po'!

Test

Ora che il codice è stato implementato, bisogna caricare le fixture per i test con i dati campione:

# 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

Si può ricostruire di nuovo tutto e caricare i dati con le fixture:

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

Ora è stato creato tutto e i dati con le fixture sono stati caricati; quindi si può eseguire un test per vedere se i contatori vengono tenuti aggiornati:

$ 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'

Si vedrà che il modello Thread ha una colonna il cui valore è tre. Se si vuole cancellare uno dei post con il seguente codice, verrà decrementato il valore in automatico:

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

Si può vedere che il record è stato cancellato e il contatore aggiornato:

$ 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'

Funziona anche se vengono cancellati in batch i due record rimanenti, con una query DQL delete:

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

Ora sono stati cancellati tutti i post presenti e num_posts dovrebbe essere zero:

$ 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: {  }

Questo è tutto! La speranza è che questo capitolo possa aver insegnato qualcosa sui comportamenti e che il comportamento stesso possa tornare utile!

Utilizzo della cache nei risultati di Doctrine

Nelle applicazioni web fortemente trafficate, è una necessità comune mettere in cache le informazioni per risparmiare risorse nella CPU. Con Doctrine 1.2 sono stati realizzati molti miglioramenti alla cache, che forniscono un maggior controllo sulla cancellazione delle voci della cache, dai gestori della cache. In precedenza non era possibile specificare la chiave di cache utilizzata per memorizzare la cache in ingresso e quindi non era possibile identificare tale voce al fine di cancellarla.

In questa sezione verrà mostrato un semplice esempio di come si possa utilizzare il risultato di un set di cache, per mettere in cache tutte le query relative all'utente, nonché utilizzare gli eventi per essere sicuri che siano correttamente cancellati quando alcuni dati vengono cambiati.

Lo schema

Per questo esempio, verrà usato il seguente schema:

# config/doctrine/schema.yml
User:
  columns:
    username:
      type: string(255)
      notnull: true
      unique: true
    password:
      type: string(255)
      notnull: true

Ora verrà ricreato tutto dallo schema, con il seguente comando:

$ php symfony doctrine:build --all

Una volta fatto questo, si dovrebbe essere generata la seguente classe User:

// lib/model/doctrine/User.class.php
/**
 * User
 *
 * This class has been auto-generated by the Doctrine ORM Framework
 *
 * @package    ##PACKAGE##
 * @subpackage ##SUBPACKAGE##
 * @author     ##NAME## <##EMAIL##>
 * @version    SVN: $Id: Builder.php 6508 2009-10-14 06:28:49Z jwage $
 */
class User extends BaseUser
{
}

Notare che più avanti con l'articolo, sarà necessario aggiungere del codice a questa classe.

Configurazione dei risultati della cache

Per poter utilizzare i risultati della cache, è necessario configurare un gestore di cache per le query da usare. Questo può essere fatto impostando l'attributo ATTR_RESULT_CACHE. Verrà usato il driver di cache APC in quanto è la scelta migliore per un sito in produzione. Se non si ha a disposizione APC, a fini di prova si può usare il driver Doctrine_Cache_Db o Doctrine_Cache_Array.

È possibile impostare questo attributo nella classe ProjectConfiguration. Definire un metodo configureDoctrine():

// config/ProjectConfiguration.class.php
 
// ...
class ProjectConfiguration extends sfProjectConfiguration
{
  // ...
 
  public function configureDoctrine(Doctrine_Manager $manager)
  {
    $manager->setAttribute(Doctrine_Core::ATTR_RESULT_CACHE, new Doctrine_Cache_Apc());
  }
}

Ora che il gestore dei risultati della cache è configurato, si può iniziare a utilizzare realmente questo driver, per mettere in cache i risultati delle query.

Esempio di query

Immaginare di avere nell'applicazione una certa quantità di query relative all'utente e di volere cancellarle tutte le volte che alcuni dati utente sono cambiati.

Ecco una semplice query che si può utilizzare per ordinare alfabeticamente un elenco di utenti:

$q = Doctrine_Core::getTable('User')
    ->createQuery('u')
    ->orderBy('u.username ASC');

Ora si è in grado di attivare il caching per questa query utilizzando il metodo useResultCache():

$q->useResultCache(true, 3600, 'users_index');

note

Notare il terzo parametro. Questa è la chiave che sarà usata per memorizzare la cache entrante per i risultati del gestore della cache. Questo permette di identificare facilmente la query e cancellarla dal gestore della cache.

Ora quando si fa la query, verrà eseguita nel database per ottenere i risultati e verranno memorizzati nel gestore della cache sotto la chiave users_index. Ogni richiesta successiva riceverà le informazioni dal gestore della cache, invece che chiederle al database:

$users = $q->execute();

note

Non solo il processo viene salvato nel database del server, ma scavalca anche l'intero processo di idratazione, poiché Doctrine salva i dati idratati. Ciò significa che si risparmieranno alcune elaborazioni sul server web.

Ora se si osserva il gestore della cache, si può vedere che c'è una voce denominata users_index:

if ($cacheDriver->contains('users_index'))
{
  echo 'la cache esiste';
}
else
{
  echo 'la cache non esiste';
}

Cancellazione della cache

Ora che la query viene memorizzata nella cache, bisogna imparare come si può cancellarla. È possibile cancellarla manualmente usando l'API del gestore della cache o utilizzando alcuni eventi per cancellare automaticamente le voci della cache quando un utente è inserito o modificato.

L'API del gestore della cache

Per il momento ci si limita a mostrare le API grezze del gestore della cache, prima di implementarle in un evento.

tip

Per poter accedere all'istanza con i risultati del gestore della cache, si può utilizzare l'istanza della classe Doctrine_Manager.

$cacheDriver = $manager->getAttribute(Doctrine_Core::ATTR_RESULT_CACHE);

Se non si ha già accesso alla variabile $manager si può recuperare l'istanza con il seguente codice.

$manager = Doctrine_Manager::getInstance();

Ora si può iniziare a utilizzare l'API per cancellare le entrate nella cache:

$cacheDriver->delete('users_index');

Probabilmente si avrà più di un utente relativo alla query messa in cache e la chiave prefissata con users_, quindi il metodo delete() in questo caso non funziona correttamente. Allora si può usare il metodo deleteByPrefix() per essere sicuri di cancellare la cache di tutte le query relative all'utente:

$cacheDriver->deleteByPrefix('users_');

Ci sono anche un altro paio di comodi metodi che si possono utilizzare per cancellare le voci della cache se deleteByPrefix() non è sufficiente:

  • deleteBySuffix($suffix): Cancella le voci della cache che hanno il suffisso passato;

  • deleteByRegex($regex): Cancella le voci della cache che combaciano con l'espressione regolare passata;

  • deleteAll(): Cancella tutte le voci della cache.

Cancellazione tramite eventi

Il modo ideale per svuotare la cache, sarebbe quello di cancellarla automaticamente ogni volta che alcuni dati utente vengono modificati. Si può fare questo grazie all'implementazione di un evento postSave() nella definizione della classe del modello User.

Ci si ricorda della classe User di cui si è parlato in precedenza? Ora bisogna aggiungere del codice a essa, quindi la si apre nell'editor e aggiungendo il seguente metodo postSave():

// lib/model/doctrine/User.class.php
 
class User extends BaseUser
{
  // ...
 
  public function postSave($event)
  {
    $cacheDriver = $this->getTable()->getAttribute(Doctrine_Core::ATTR_RESULT_CACHE);
    $cacheDriver->deleteByPrefix('users_');
  }
}

Ora se si vuole aggiornare un utente o inserirne uno nuovo si dovrebbe cancellare la cache per tutte le query relative all'utente:

$user = new User();
$user->username = 'jwage';
$user->password = 'changeme';
$user->save();

La prossima volta che le query vengono invocate, vedranno che la cache non esiste, andranno a recuperare i dati dal database e li metteranno nuovamente in cache per le richieste successive.

Anche se questo esempio è molto semplice, dovrebbe dimostrare bene come si può usare queste funzionalità per implementare una cache tarata finemente sulle query di Doctrine.

Scrivere un idratante per Doctrine

Una delle caratteristiche chiave di Doctrine è la capacità di trasformare un oggetto Doctrine_Query in vari tipi di strutture di risultati. Questo è il lavoro dell'idratante di Doctrine, ma, fino a Doctrine 1.2, gli idratatori erano tutti cablati nel codice e non utilizzabili dagli sviluppatori per personalizzarli. Ora che questo è cambiatoi, è possibile scrivere un idratante personalizzato e creare qualunque struttura dati che si desidera, in base ai dati del database che si vogliono ottenere, quando si esegue una istanza di Doctrine_Query.

In questo esempio verrà costruito un idratante estremamente semplice e facile da capire, nonché molto utile. Esso consente di selezionare due colonne e idratare i dati in un array dove la prima colonna selezionata è la chiave e la seconda colonna selezionata è il valore.

Lo schema e le fixture

Prima di iniziare è necessario uno schema su cui fare le prove. Basta usare un semplice modello User:

# config/doctrine/schema.yml
User:
  columns:
    username: string(255)
    is_active: string(255)

C'è anche bisogno di alcune fixture per i dati, che sono riportate di seguito:

# data/fixtures/data.yml
User:
  user1:
    username: jwage
    password: changeme
    is_active: 1
  user2:
    username: jonwage
    password: changeme
    is_active: 0

Ora verrà creato tutto il necessario con il comando seguente:

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

Scrivere un idratante

Per scrivere un idratante tutto quello che bisogna fare è scrivere una nuova classe che estende Doctrine_Hydrator_Abstract e implementa un metodo hydrateResultSet($stmt). Questo riceve l'istanza PDOStatement usata per eseguire la query. Si può utilizzare questa dichiarazione per ottenere i risultati grezzi della query di PDO e quindi trasformarli nella struttura voluta.

Creareo una nuova classe chiamata KeyValuePairHydrator e metterla nella cartella lib/ in modo che symfony possa caricarla automaticamente:

// lib/KeyValuePairHydrator.class.php
class KeyValuePairHydrator extends Doctrine_Hydrator_Abstract
{
  public function hydrateResultSet($stmt)
  {
    return $stmt->fetchAll(Doctrine_Core::FETCH_NUM);
  }
}

Il codice di cui sopra, per come è ora, restituirebbe i dati esattamente come vengono forniti da PDO. Questo non è esattamente quello che si vuole. Si vogliono trasformare i dati in una struttura di coppie chiave => valore. Quindi bisogna modificare il metodo hydrateResultSet() per fargli fare quello di cui si ha bisogno:

// lib/KeyValuePairHydrator.class.php
class KeyValuePairHydrator extends Doctrine_Hydrator_Abstract
{
  public function hydrateResultSet($stmt)
  {
    $results = $stmt->fetchAll(Doctrine_Core::FETCH_NUM);
    $array = array();
    foreach ($results as $result)
    {
      $array[$result[0]] = $result[1];
    }
 
    return $array;
  }
}

Bene è stato facile! Il codice dell'idratante è finito e fa esattamente quello che si voleva, ora non resta che usarlo!

Utilizzare un idratante

Per utilizzare e testare l'idratante, prima è necessario registrarlo su Doctrine in modo che quando vengono eseguite delle query, Doctrine sia a conoscenza della classe idratante che è stata scritta.

Per farlo, bisogna registrarlo nell'istanza Doctrine_Manager di ProjectConfiguration:

// config/ProjectConfiguration.class.php
 
// ...
class ProjectConfiguration extends sfProjectConfiguration
{
  // ...
 
  public function configureDoctrine(Doctrine_Manager $manager)
  {
    $manager->registerHydrator('key_value_pair', 'KeyValuePairHydrator');
  }
}

Ora che l'idratante è registrato, si è in grado di utilizzarlo con le istranze di Doctrine_Query. Ecco un esempio:

$q = Doctrine_Core::getTable('User')
  ->createQuery('u')
  ->select('u.username, u.is_active');
 
$results = $q->execute(array(), 'key_value_pair');
print_r($results);

L'esecuzione della query di cui sopra, con le fixture dei dati definiti in precedenza ottiene il seguente risultato:

Array
(
    [jwage] => 1
    [jonwage] => 0
)

Bene questo è tutto! Abbastanza semplice vero? Si spera che quanto detto possa tornare utile e che come risultato la comunità crei dei nuovi e interessanti idratatori!