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 … :
- INSERT INTO
- my_table
- (`date`, `source`, `hits`)
- VALUES
- (CURDATE(), 'referer', 1)
- ON DUPLICATE KEY UPDATE
- 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 :
- Pas de primary sur des champs DATE avec Doctrine : peut être contourné avec un type particulier ;
- 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 :
- on incrémente la ligne qui correspond à la date et la source ;
- si aucun enregistrement n’a été mis à jour, on crée une nouvelle ligne dans la base ;
- 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 :
- La ligne éxiste déjà (majorité des cas dans mon contexte) : alors on fait une seule requête, comme avant ;
- 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 ;
- 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 :
- <?php
- namespace Tests\Model;
- class HitsManager
- {
- /** @var EntityManager */
- protected $em;
- /**
- * Executes the update statement
- * @param \DateTime $date The date
- * @param string $source The source value
- * @return integer 1 if successful (thanks to unique key), 0 otherwise
- */
- protected function doInc($date, $source)
- {
- return $em
- ->createQuery('
- UPDATE \Tests\Entity\Hits h
- SET h.hits = h.hits + 1
- WHERE h.date = :date AND h.source = :source
- ')
- ->setParameter('date', $date)
- ->setparameter('source', $source)
- ->execute();
- }
- /**
- * Executes the insert ... on duplicate key update
- * @param \DateTime $date The date
- * @param string $source The source value
- * @return boolean True if successful, false otherwise
- */
- public function inc(\DateTime $date, $source)
- {
- // Try to increment
- if (0 < $this->doInc($date, $source)) {
- // We were successful
- return true;
- }
- // Failed to increment, try to create new one
- $hit = new \Tests\Entity\Hits();
- $hit->setDate($date);
- $hit->setSource($source);
- $hit->setHits(1);
- try {
- $this->em->persist($hit);
- // Flush to generate \Exception if failure !
- $this->em->flush();
- return true;
- } catch (\Doctrine\DBAL\DBALException $e) {
- // _One reason_ might be we have duplicate key
- return (boolean) $this->doInc($date, $source);
- }
- }
- }