Alors voilà le topo : j’ai une table qui compte mes visiteurs en fonction de leur source :

colonne type index
date DATE PRIMARY
source VARCHAR(50) PRIMARY
hits INT

A chaque visite, j’incrémente le nombre de hits en fonction de la date et de la source. L’approche naïve, c’est d’aller chercher l’objet, de le créer au besoin, de l’incrémenter, et de le sauver. Le principal problème de ce genre de fonctionnement est la gestion de la concurrence : que se passe-t-il si j’ai deux visiteurs qui arrivent au même moment sur mes serveurs ? Je vais sans doute avoir une erreur…

Avant, j’utilisait INSERT … ON DUPLICATE KEY … :

  1. INSERT INTO
  2.   my_table
  3.   (`date`, `source`, `hits`)
  4. VALUES
  5.   (CURDATE(), 'referer', 1)
  6. ON DUPLICATE KEY UPDATE
  7.   hits = hits + 1

Mais ça, c’était avant Doctrine…

Doctrine est un ORM…

… compatible avec beaucoup de moteurs de bases de données, du coup pas possible d’utiliser certaines spécificités du langage à moins de

  • utiliser des requêtes natives ;
  • étendre Doctrine pour faire ce que vous voulez que ça fasse, par exemple rajouter des types.

Bref, c’est du boulot ! Alors si on peut trouver une petite solution de contournement…

Notre problème est double :

  1. Pas de primary sur des champs DATE avec Doctrine : peut être contourné avec un type particulier ;
  2. Pas de INSERT ON DUPLICATE KEY UPDATE : peut être contourné en utilisant une requête native.

Mais bon, si on utilise un ORM, c’est parce qu’on préfère gérer des entités et du DQL 🙂

Utiliser Doctrine, son Entity Manager et le DQL

L’approche que je propose n’est pas des plus optimisées par rapport à la requête d’origine, mais a le mérite d’être compatible DQL.

1ère étape : Modification de l’entité

On préfère travailler avec des entités avec des IDs, on sait jamais ce qu’on va en faire, alors mieux vaut prévoir le coup ! Et même si Doctrine ne gère pas les dates en PRIMARY, ça gère les dates en Unique 🙂

colonne type index
id INT PRIMARY
date DATE UNIQUE(date_source)
source VARCHAR(50) UNIQUE(date_source)
hits INT

Notez que la contrainte unique est sur les deux colonnes date et source.

2ème étape : Traduction de l’INSERT .. UPDATE

Elle se décompose en trois étapes :

  1. on incrémente la ligne qui correspond à la date et la source ;
  2. si aucun enregistrement n’a été mis à jour, on crée une nouvelle ligne dans la base ;
  3. si au moment de l’écriture, ça génère une erreur de duplicate key (sur la UniqueConstraint), alors on relance l’incrément qui cette fois-ci devrait fonctionner 🙂

On se retrouve avec 3 cas :

  1. La ligne éxiste déjà (majorité des cas dans mon contexte) : alors on fait une seule requête, comme avant ;
  2. La ligne n’éxiste pas : on la crée et le INSERT se passe correctement : on a fait 2 requêtes, mais qu’une seule fois par jour et par source ;
  3. La ligne n’éxistait pas, mais a été créée entre temps par un autre visiteurs : on incrémente cette ligne. On a fait 3 requêtes, mais comme avant ça n’arrive qu’une seule fois par jour et par source.

Exemple de code :

  1. <?php
  2.  
  3. namespace Tests\Model;
  4.  
  5. class HitsManager
  6. {
  7.     /** @var EntityManager */
  8.     protected $em;
  9.  
  10.     /**
  11.      * Executes the update statement
  12.      * @param  \DateTime $date   The date
  13.      * @param  string    $source The source value
  14.      * @return integer           1 if successful (thanks to unique key), 0 otherwise
  15.      */
  16.     protected function doInc($date, $source)
  17.     {
  18.         return $em
  19.             ->createQuery('
  20.                UPDATE \Tests\Entity\Hits h
  21.                SET h.hits = h.hits + 1
  22.                WHERE h.date = :date AND h.source = :source
  23.            ')
  24.             ->setParameter('date', $date)
  25.             ->setparameter('source', $source)
  26.             ->execute();
  27.     }
  28.  
  29.     /**
  30.      * Executes the insert ... on duplicate key update
  31.      * @param  \DateTime $date   The date
  32.      * @param  string    $source The source value
  33.      * @return boolean           True if successful, false otherwise
  34.      */
  35.     public function inc(\DateTime $date, $source)
  36.     {
  37.         // Try to increment
  38.         if (0 < $this->doInc($date, $source)) {
  39.             // We were successful
  40.             return true;
  41.         }
  42.  
  43.         // Failed to increment, try to create new one
  44.         $hit = new \Tests\Entity\Hits();
  45.         $hit->setDate($date);
  46.         $hit->setSource($source);
  47.         $hit->setHits(1);
  48.         try {
  49.             $this->em->persist($hit);
  50.             // Flush to generate \Exception if failure !
  51.             $this->em->flush();
  52.  
  53.             return true;
  54.         } catch (\Doctrine\DBAL\DBALException $e) {
  55.             // _One reason_ might be we have duplicate key
  56.             return (boolean) $this->doInc($date, $source);
  57.         }
  58.     }
  59. }
  60.  

Leave a Reply