<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Doctrine on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/tags/doctrine/</link><description>Recent content in Doctrine on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Sun, 17 May 2026 15:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/tags/doctrine/index.xml" rel="self" type="application/rss+xml"/><item><title>Onze sur douze</title><link>https://guillaumedelre.github.io/fr/2026/05/17/onze-sur-douze/</link><pubDate>Sun, 17 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/05/17/onze-sur-douze/</guid><description>Part 8 of 8 in &amp;quot;Symfony vers le Cloud : Douze Facteurs, Treize Services&amp;quot;: Onze facteurs résolus proprement. Le douzième : des migrations Doctrine dans l&amp;#39;entrypoint, en attente d&amp;#39;une question de gouvernance que le code seul ne peut pas trancher.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Le <code>composer.json</code> de chaque service avait ça dans sa section <code>post-install-cmd</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#e6db74">&#34;post-install-cmd&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console cache:clear --env=prod&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console doctrine:migrations:migrate --no-interaction&#34;</span>
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p><code>post-install-cmd</code> s&rsquo;exécute pendant <code>composer install</code>, qui dans le Dockerfile de production tourne au moment du build de l&rsquo;image. Il n&rsquo;y a pas de base de données disponible pendant un build Docker. La commande de migration échouait silencieusement, se connectait à rien, ou était ignorée par Doctrine faute de schéma à comparer. Dans tous les cas, elle ne migrait rien.</p>
<p>C&rsquo;est une violation nette du <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Facteur XII</a>
 : les processus d&rsquo;administration — migrations, scripts ponctuels, commandes console — doivent s&rsquo;exécuter dans le même environnement que l&rsquo;application, contre les vraies données de production. Les faire tourner au build inverse la relation. L&rsquo;image ne devrait pas savoir qu&rsquo;il y a une base de données. La base devrait être là quand l&rsquo;image en a besoin.</p>
<h2 id="le-déplacement-vers-lentrypoint">Le déplacement vers l&rsquo;entrypoint</h2>
<p>La commande de migration a quitté <code>composer.json</code> pour <code>docker-entrypoint.sh</code>. Le changement semble petit dans un diff. Les implications ne le sont pas.</p>
<p>L&rsquo;entrypoint s&rsquo;exécute quand le container démarre, pas quand l&rsquo;image est construite. La base de données est accessible. L&rsquo;entrypoint l&rsquo;attend — jusqu&rsquo;à 60 secondes, une tentative par seconde — avant de faire quoi que ce soit :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">until</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span> <span style="color:#f92672">||</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  DATABASE_ERROR<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>php bin/console dbal:run-sql -q <span style="color:#e6db74">&#34;SELECT 1&#34;</span> 2&gt;&amp;1<span style="color:#66d9ef">)</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    sleep <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#66d9ef">$((</span>ATTEMPTS_LEFT_TO_REACH_DATABASE <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span><span style="color:#66d9ef">))</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;</span>$DATABASE_ERROR<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Si la base ne répond pas dans les 60 secondes, le container sort en erreur et Kubernetes le redémarre. Une fois la base prête, la migration tourne :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span> find ./migrations -iname <span style="color:#e6db74">&#39;*.php&#39;</span> -print -quit <span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Deux changements par rapport à la commande d&rsquo;origine : <code>--all-or-nothing</code> garantit que si une migration dans un batch échoue, le batch entier est annulé. Et le guard <code>find</code> passe la commande si aucun fichier de migration n&rsquo;existe — utile pour les services qui n&rsquo;utilisent pas les migrations Doctrine.</p>
<p>C&rsquo;est franchement mieux. La base est là. La migration tourne dans le vrai environnement. Le flag <code>--all-or-nothing</code> apporte une atomicité que la version au build n&rsquo;avait jamais eue.</p>
<h2 id="ce-que-ça-ne-résout-pas">Ce que ça ne résout pas</h2>
<p>Deux pods qui redéploient simultanément exécutent tous les deux l&rsquo;entrypoint. Tous les deux atteignent la base. Tous les deux trouvent des migrations en attente. Tous les deux appellent <code>doctrine:migrations:migrate</code>.</p>
<p>Doctrine a un mécanisme de verrouillage : une table <code>doctrine_migration_versions</code> qui enregistre quelles migrations ont tourné, et la commande la consulte avant d&rsquo;appliquer quoi que ce soit. Dans les conditions normales c&rsquo;est suffisant : le deuxième pod trouve la table à jour et sort proprement. Les cas de défaillance réels sont plus précis : une migration assez longue pour dépasser le timeout du verrou de base de données, ce qui laisse un deuxième runner démarrer la même migration avant que le premier ait terminé ; ou un pod qui se vautre à mi-migration avant d&rsquo;avoir enregistré la version dans la table, laissant le schéma dans un état appliqué-mais-non-enregistré que le pod suivant va tenter d&rsquo;appliquer à nouveau.</p>
<p>La position de l&rsquo;équipe est explicite : un downtime léger au déploiement est acceptable. Les versions d&rsquo;application ne sont pas nécessairement compatibles avec des versions de schéma plus anciennes, donc faire tourner N et N+1 simultanément contre la même base ne serait de toute façon pas sûr. La stratégie de déploiement est Recreate : tous les anciens pods sont terminés avant que les nouveaux ne démarrent. La migration tourne au premier démarrage, sans chevauchement entre les versions. Ça fonctionne.</p>
<p>Mais &ldquo;ça fonctionne&rdquo; et &ldquo;c&rsquo;est la bonne architecture&rdquo; sont deux réponses différentes.</p>
<h2 id="ce-que-feraient-les-alternatives">Ce que feraient les alternatives</h2>
<p>Le <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Facteur XII</a>
 dit que les processus d&rsquo;administration doivent tourner dans des &ldquo;processus ponctuels&rdquo;. Un processus qui tourne une fois, dans un but précis, contre l&rsquo;environnement de production. L&rsquo;entrypoint n&rsquo;est pas ponctuel — il tourne à chaque démarrage de container, y compris les redémarrages, les événements de scaling, et les déplacements de pods par Kubernetes.</p>
<p>Trois alternatives existent, chacune avec une réponse différente à la question de la propriété :</p>
<p><strong>Un init container Kubernetes</strong> tourne avant le container principal, dans le même pod. Il peut exécuter la migration, sortir, et laisser le container principal démarrer seulement après son succès. La migration est isolée du runtime applicatif. Le problème : l&rsquo;init container est une image supplémentaire à construire et maintenir, et il tourne à chaque démarrage de pod — une plateforme de 14 services démarrant simultanément a toujours une race potentielle.</p>
<p><strong>Un Kubernetes Job</strong> tourne une fois, à la demande ou déclenché par le pipeline de déploiement. Il peut être configuré pour s&rsquo;exécuter avant la mise à jour des pods — séquentiel, isolé, avec un signal clair de succès ou d&rsquo;échec. La race condition disparaît. La complexité se déplace vers le processus de déploiement : le Job doit se terminer avant que le rollout du Deployment commence, et le pipeline CI doit coordonner les deux.</p>
<p><strong>Un hook Helm</strong> est le même concept exprimé de façon déclarative dans le chart Helm. Un hook <code>pre-upgrade</code> exécute la migration avant la mise à jour des pods applicatifs. C&rsquo;est la réponse la plus idiomatique pour Kubernetes. Ça signifie aussi que le chart Helm est désormais responsable de l&rsquo;exécution des migrations — une décision qui appartient à qui possède le chart.</p>
<p>Cette dernière phrase explique pourquoi l&rsquo;entrypoint n&rsquo;a pas changé. Déplacer les migrations hors de l&rsquo;application signifie décider que l&rsquo;infrastructure de déploiement — pas l&rsquo;application elle-même — est responsable du schéma. C&rsquo;est une question de gouvernance autant que de technique, et les questions de gouvernance prennent plus de temps à résoudre que les changements de code.</p>
<h2 id="la-fin-honnête">La fin honnête</h2>
<p>Le bloc de migration dans l&rsquo;entrypoint, c&rsquo;est deux lignes. Littéralement : le guard <code>if [ &quot;$( find ./migrations... )&quot; ]</code>, et le <code>php bin/console doctrine:migrations:migrate</code> qui suit. Onze autres facteurs ont des résolutions nettes. Le cache est passé sur Redis. Les logs vont vers stdout. Le système de fichiers est un bucket S3. Le CI assemble les images de production depuis le même commit qu&rsquo;il teste. Les secrets ne voyagent plus dans les layers d&rsquo;image.</p>
<p>Le Facteur XII a une réponse. Ce n&rsquo;est juste pas la réponse finale.</p>
<p>Les migrations tournent au démarrage, avec une vraie base de données, avec atomicité, avec une fenêtre de retry bornée. C&rsquo;est mieux que de tourner au build contre rien. La question de savoir si elles finiront dans un Job ou un hook Helm est une conversation sur qui possède le schéma — une question à laquelle un <code>kubectl apply</code> ne peut pas répondre.</p>
]]></content:encoded></item><item><title>La recherche full-text PostgreSQL avec Doctrine, sans une ligne de SQL brut</title><link>https://guillaumedelre.github.io/fr/2025/02/10/la-recherche-full-text-postgresql-avec-doctrine-sans-une-ligne-de-sql-brut/</link><pubDate>Mon, 10 Feb 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2025/02/10/la-recherche-full-text-postgresql-avec-doctrine-sans-une-ligne-de-sql-brut/</guid><description>Comment nous avons superposé des types DBAL personnalisés et des wrappers DQL sur postgresql-for-doctrine pour intégrer la recherche full-text PostgreSQL dans un projet Symfony API Platform.</description><content:encoded><![CDATA[<p>Le champ de recherche de la médiathèque renvoyait des résultats en 800 millisecondes en staging. En production, il y avait quarante fois plus de lignes. Le plan d&rsquo;exécution révélait un sequential scan: aucun index sollicité, aucune façon d&rsquo;y remédier avec un B-tree classique. L&rsquo;équipe produit voulait aussi une recherche multi-mots: taper &ldquo;interview président&rdquo;, obtenir des résultats contenant les deux termes. Une requête <code>LIKE</code> avec des wildcards n&rsquo;a pas de manière propre d&rsquo;exprimer ça sans conditions indépendantes multiples, chacune nécessitant son propre scan.</p>
<p>PostgreSQL embarque une recherche full-text depuis plus de quinze ans. La plateforme tournait déjà sous PostgreSQL. Le hic: le projet utilise Doctrine ORM, et Doctrine ne sait pas nativement ce qu&rsquo;est un <code>tsvector</code>.</p>
<p>Une bibliothèque communautaire, <a href="https://github.com/martin-georgiev/postgresql-for-doctrine" target="_blank" rel="noopener noreferrer">postgresql-for-doctrine</a>, couvre une partie de cette lacune. Elle enregistre des fonctions DQL basiques comme <code>TO_TSQUERY</code>, <code>TO_TSVECTOR</code>, et l&rsquo;opérateur de correspondance <code>@@</code> en tant que pièces atomiques séparées. La fondation était là. Trois choses restaient à construire par-dessus.</p>
<h2 id="le-type-que-doctrine-na-jamais-vu">Le type que Doctrine n&rsquo;a jamais vu</h2>
<p><a href="https://www.postgresql.org/docs/current/datatype-textsearch.html" target="_blank" rel="noopener noreferrer">La recherche full-text de PostgreSQL</a> repose sur deux types: <code>tsvector</code> (une liste pré-traitée de tokens normalisés) et <code>tsquery</code> (une expression de recherche). On maintient une colonne <code>tsvector</code>, on l&rsquo;indexe avec GIN, et on interroge avec l&rsquo;opérateur <code>@@</code>.</p>
<p>Le DBAL de Doctrine ne livre aucun type <code>tsvector</code>. Déclarer <code>#[ORM\Column(type: 'tsvector')]</code> sans l&rsquo;enregistrer au préalable lève une <code>UnknownColumnTypeException</code>. La solution: un type DBAL personnalisé:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsVector</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Type</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">final</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> <span style="color:#a6e22e">DBAL_TYPE</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;tsvector&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSQLDeclaration</span>(<span style="color:#66d9ef">array</span> $column, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DBAL_TYPE</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getName</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DBAL_TYPE</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToDatabaseValueSQL</span>(<span style="color:#a6e22e">string</span> $sqlExpr, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#34;to_tsvector(&#39;simple&#39;, %s)&#34;</span>, $sqlExpr);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToDatabaseValue</span>(<span style="color:#a6e22e">mixed</span> $value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">mixed</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">is_array</span>($value) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">isset</span>($value[<span style="color:#e6db74">&#39;data&#39;</span>])) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> $value[<span style="color:#e6db74">&#39;data&#39;</span>];
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">is_string</span>($value) <span style="color:#f92672">?</span> $value <span style="color:#f92672">:</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMappedDatabaseTypes</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [<span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DBAL_TYPE</span>];
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>La méthode intéressante est <code>convertToDatabaseValueSQL()</code>. Doctrine l&rsquo;appelle pour envelopper le placeholder SQL avant que la valeur n&rsquo;atteigne la base de données. La valeur écrite devient automatiquement <code>to_tsvector('simple', ?)</code> à la frontière DBAL, sans étape supplémentaire côté appelant.</p>
<p>On enregistre le type dans <code>doctrine.yaml</code>, puis on mappe la colonne sur l&rsquo;entité:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">doctrine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dbal</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">types</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">tsvector</span>: <span style="color:#ae81ff">App\Doctrine\DBAL\Types\TsVector</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ORM\Column(type: &#39;tsvector&#39;, nullable: true)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $textSearch <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span></code></pre></div><p>Côté PHP, la valeur est une simple chaîne. La conversion en vrai <code>tsvector</code> se fait invisiblement au niveau DBAL.</p>
<p>Nous avons utilisé le dictionnaire <code>'simple'</code>, qui tokenise sur les espaces et la ponctuation sans stemming spécifique à une langue. La plateforme gère plusieurs langues, et les règles de stemming français auraient cassé l&rsquo;espagnol. Simple suffit largement pour la phonétique.</p>
<h2 id="garder-la-colonne-à-jour">Garder la colonne à jour</h2>
<p>Une colonne <code>tsvector</code> est une donnée dérivée: elle doit rester synchronisée avec les champs source chaque fois que l&rsquo;entité change. Un event listener Doctrine s&rsquo;en charge:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsDoctrineListener(event: Events::prePersist)]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">#[AsDoctrineListener(event: Events::preUpdate)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">MediaTsVectorSubscriber</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">prePersist</span>(<span style="color:#a6e22e">PrePersistEventArgs</span> $event)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>() <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">Media</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">updateTextSearch</span>($event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>());
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">preUpdate</span>(<span style="color:#a6e22e">PreUpdateEventArgs</span> $event)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>() <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">Media</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">updateTextSearch</span>($event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>());
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">updateTextSearch</span>(<span style="color:#a6e22e">Media</span> $entity)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setTextSearch</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#39;%s %s&#39;</span>, $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getTitle</span>(), $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getCaption</span>())
</span></span><span style="display:flex;"><span>        );
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Avant chaque persist et update, le subscriber concatène les champs qui doivent être recherchables dans <code>textSearch</code>. Doctrine flush la chaîne combinée, le type DBAL l&rsquo;enveloppe dans <code>to_tsvector('simple', ...)</code>, et PostgreSQL stocke la forme tokenisée.</p>
<p>Une subtilité: la valeur côté PHP est <code>&quot;title caption&quot;</code>, pas la sortie tsvector réelle. La base affiche <code>'caption' 'title'</code> (tokens triés), mais l&rsquo;entité contient une chaîne brute. C&rsquo;est attendu: la conversion est une responsabilité DBAL, pas PHP. Ça peut dérouter le débogage jusqu&rsquo;à ce qu&rsquo;on se souvienne où se situe la frontière.</p>
<h2 id="étendre-dql-avec-les-opérateurs-fts">Étendre DQL avec les opérateurs FTS</h2>
<p>Le DQL de Doctrine couvre les opérations SQL courantes, mais tout ce qui est spécifique à PostgreSQL est hors périmètre. C&rsquo;est là que <code>postgresql-for-doctrine</code> entre en jeu: il enregistre <code>TO_TSQUERY</code>, <code>TO_TSVECTOR</code>, et <code>TSMATCH</code> comme fonctions DQL individuelles. Écrire une requête full-text en DQL sans lui signifierait basculer en SQL natif.</p>
<p>Les fonctions de la bibliothèque sont atomiques, cependant. Chacune correspond à un appel SQL. Exprimer une vérification de correspondance complète en DQL ressemble à <code>TSMATCH(o.textSearch, TO_TSQUERY(:term))</code>. Assez lisible, mais l&rsquo;équipe voulait quelque chose de plus compact: une seule fonction DQL encodant à la fois l&rsquo;opérateur de correspondance et le type de requête, y compris <code>websearch_to_tsquery</code> que <code>postgresql-for-doctrine</code> ne fournissait pas.</p>
<p>La solution: des <a href="https://www.doctrine-project.org/projects/doctrine-orm/en/latest/cookbook/dql-user-defined-functions.html" target="_blank" rel="noopener noreferrer">fonctions DQL personnalisées</a> via <code>FunctionNode</code>. On parse la syntaxe DQL, puis on émet du SQL. Toutes les fonctions FTS partagent la même signature à deux arguments, donc une classe abstraite de base gère le parsing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">abstract</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsFunction</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">FunctionNode</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">PathExpression</span><span style="color:#f92672">|</span><span style="color:#a6e22e">Node</span><span style="color:#f92672">|</span><span style="color:#66d9ef">null</span> $ftsField <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">PathExpression</span><span style="color:#f92672">|</span><span style="color:#a6e22e">Node</span><span style="color:#f92672">|</span><span style="color:#66d9ef">null</span> $queryString <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">parse</span>(<span style="color:#a6e22e">Parser</span> $parser)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_IDENTIFIER</span>);
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_OPEN_PARENTHESIS</span>);
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ftsField</span> <span style="color:#f92672">=</span> $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">StringPrimary</span>();
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_COMMA</span>);
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queryString</span> <span style="color:#f92672">=</span> $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">StringPrimary</span>();
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_CLOSE_PARENTHESIS</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Chaque classe concrète implémente <code>getSql()</code> pour émettre son expression PostgreSQL:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// e.textSearch @@ websearch_to_tsquery(&#39;simple&#39;, :term)
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsWebsearchQueryFunction</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">TsFunction</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ftsField</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span><span style="color:#e6db74">&#34; @@ websearch_to_tsquery(&#39;simple&#39;, &#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queryString</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)<span style="color:#f92672">.</span><span style="color:#e6db74">&#39;)&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ts_rank(e.textSearch, to_tsquery(:term)) pour le tri par pertinence
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsRankFunction</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">TsFunction</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;ts_rank(&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ftsField</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span><span style="color:#e6db74">&#39;, to_tsquery(&#39;</span><span style="color:#f92672">.</span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queryString</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)<span style="color:#f92672">.</span><span style="color:#e6db74">&#39;))&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">doctrine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">orm</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">entity_managers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">default</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">dql</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">string_functions</span>:
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tswebsearchquery</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsWebsearchQueryFunction</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tsrank</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsRankFunction</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tsquery</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsQueryFunction</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tsplainquery</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsPlainQueryFunction</span>
</span></span></code></pre></div><p><code>websearch_to_tsquery</code> est le bon choix pour la recherche côté utilisateur: les espaces deviennent des AND, les chaînes entre guillemets deviennent des phrases, <code>-mot</code> exclut un terme. Inutile d&rsquo;apprendre aux utilisateurs à taper <code>interview &amp; président</code>. C&rsquo;est disponible depuis PostgreSQL 11. Sur les versions antérieures, <code>plainto_tsquery</code> est l&rsquo;équivalent le plus proche.</p>
<h2 id="le-filtre-api-platform-et-lindex-gin">Le filtre API Platform et l&rsquo;index GIN</h2>
<p>Avec les fonctions DQL enregistrées, le filtre API Platform est simple. Un <code>AbstractFilter</code> personnalisé appelle directement la fonction DQL dans le <code>QueryBuilder</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TextSearchFilter</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">AbstractFilter</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">filterProperty</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">string</span> $property,
</span></span><span style="display:flex;"><span>        $value,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">QueryBuilder</span> $queryBuilder,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">QueryNameGeneratorInterface</span> $queryNameGenerator,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">string</span> $resourceClass,
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">?</span><span style="color:#a6e22e">Operation</span> $operation <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">array</span> $context <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    )<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#e6db74">&#39;textSearch&#39;</span> <span style="color:#f92672">!==</span> $property <span style="color:#f92672">||</span> <span style="color:#66d9ef">empty</span>($value)) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $queryBuilder
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">andWhere</span>(<span style="color:#e6db74">&#39;tswebsearchquery(o.textSearch, :value) = true&#39;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setParameter</span>(<span style="color:#e6db74">&#39;:value&#39;</span>, $value);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getDescription</span>(<span style="color:#a6e22e">string</span> $resourceClass)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [];
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>On l&rsquo;applique sur l&rsquo;entité avec la déclaration d&rsquo;index:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ORM\Index(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">columns</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;text_search&#39;</span>],
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;media_text_search_idx_gin&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">options</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;USING&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;gin (text_search)&#39;</span>]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiFilter(TextSearchFilter::class, properties: [&#39;textSearch&#39; =&gt; &#39;partial&#39;])]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Media</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[ORM\Column(type: &#39;tsvector&#39;, nullable: true)]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $textSearch <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>L&rsquo;option <code>USING gin</code> n&rsquo;est pas négociable. Un index B-tree standard sur une colonne <code>tsvector</code> est inutile: PostgreSQL ne peut pas l&rsquo;utiliser pour les requêtes <code>@@</code>. GIN (Generalized Inverted Index) fonctionne différemment: il indexe chaque token individuellement, donc les recherches par n&rsquo;importe quel token sont en <code>O(log n)</code> plutôt que <code>O(n)</code>. Sans ça, on a construit un système qui donne l&rsquo;impression d&rsquo;être rapide mais qui fait quand même un full table scan.</p>
<p>Un <code>GET /media?textSearch=interview+president</code> touche maintenant l&rsquo;index GIN et répond en quelques millisecondes quel que soit la taille de la table.</p>
<h2 id="ce-que-la-répartition-ressemblait-vraiment">Ce que la répartition ressemblait vraiment</h2>
<p>La bibliothèque couvrait les fonctions atomiques bas niveau. Le code personnalisé couvrait les lacunes: un type DBAL <code>tsvector</code> que la bibliothèque ne fournissait pas, des wrappers DQL pratiques combinant <code>@@</code> et <code>websearch_to_tsquery</code> en un seul appel, et la colle applicative reliant tout ça au système d&rsquo;événements de Doctrine et à API Platform. Aucune requête native n&rsquo;a été nécessaire.</p>
<p>La répartition vaut d&rsquo;être notée en général: <code>postgresql-for-doctrine</code> donne les briques atomiques PostgreSQL, mais il faut quand même les composer en quelque chose que le reste du code peut utiliser sans y penser. Le pattern <code>FunctionNode</code> et le hook <code>convertToDatabaseValueSQL()</code> sont les deux points d&rsquo;extension qui rendent cette composition propre. Les deux valent d&rsquo;être connus, quelle que soit la bibliothèque de départ.</p>
]]></content:encoded></item><item><title>Élagage des révisions avec des window functions et des logarithmes, quand DQL ne suffisait plus</title><link>https://guillaumedelre.github.io/fr/2020/09/27/%C3%A9lagage-des-r%C3%A9visions-avec-des-window-functions-et-des-logarithmes-quand-dql-ne-suffisait-plus/</link><pubDate>Sun, 27 Sep 2020 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2020/09/27/%C3%A9lagage-des-r%C3%A9visions-avec-des-window-functions-et-des-logarithmes-quand-dql-ne-suffisait-plus/</guid><description>Comment un score logarithmique et ROW_NUMBER() OVER PARTITION BY ont résolu la croissance incontrôlable d&amp;#39;une table de révisions après que DQL a atteint ses limites.</description><content:encoded><![CDATA[<p>Chaque mise à jour de contenu sur la plateforme crée une révision. C&rsquo;est délibéré : les éditeurs ont besoin d&rsquo;un historique sur lequel ils peuvent revenir, et la plateforme a besoin d&rsquo;une piste d&rsquo;audit. Ce que personne n&rsquo;avait anticipé, c&rsquo;était le rythme. Certains articles passent par quarante sauvegardes en un seul après-midi. Une pièce à fort trafic accumule des centaines de révisions sur sa durée de vie. Après quelques mois, la table de révisions avait plusieurs millions de lignes.</p>
<p>Les supprimer naïvement n&rsquo;était pas une option. &ldquo;Garder les 50 dernières&rdquo; perd tout contexte historique pour les articles qui n&rsquo;ont pas été touchés depuis un an. &ldquo;Garder une par jour&rdquo; perd tous les détails pour le contenu qui est activement édité. Ce dont on avait besoin, c&rsquo;était une distribution qui correspondait à la façon dont les révisions sont réellement utilisées : couverture dense pour l&rsquo;historique récent, couverture clairsemée pour l&rsquo;ancien.</p>
<p>C&rsquo;est une distribution logarithmique. Et la construire nécessitait du SQL brut.</p>
<h2 id="pourquoi-les-stratégies-simples-échouent">Pourquoi les stratégies simples échouent</h2>
<p>L&rsquo;attrait d&rsquo;une fenêtre fixe est évident : garder les N révisions les plus récentes et supprimer le reste. C&rsquo;est une ligne de SQL et zéro maths. Le problème, c&rsquo;est qu&rsquo;elle traite une révision d&rsquo;hier et une révision d&rsquo;il y a trois ans comme également précieuses, ce qu&rsquo;elles ne sont pas. Un éditeur qui ouvre un article de 2017 n&rsquo;a pas besoin de ses 50 dernières versions ; il pourrait avoir besoin d&rsquo;une par trimestre. Un article qui a été publié ce matin pourrait avoir besoin de chaque sauvegarde de la dernière heure.</p>
<p>Une stratégie temporelle (une révision par jour calendaire) a le problème inverse : elle est trop agressive pour le contenu actif. Si un article reçoit 30 sauvegardes entre 09h00 et 10h00, toutes sauf une disparaissent. Ce n&rsquo;est pas de l&rsquo;histoire, c&rsquo;est de l&rsquo;effacement.</p>
<p>Ni l&rsquo;une ni l&rsquo;autre ne peut exprimer &ldquo;garder plus de détails pour le contenu récent, moins pour le vieux&rdquo;. Cette relation est logarithmique.</p>
<h2 id="lidée-de-score">L&rsquo;idée de score</h2>
<p>L&rsquo;algorithme assigne à chaque révision un score basé sur son âge, puis garde seulement une révision par bucket de score. La formule de score produit des valeurs hautes et bien espacées pour les révisions récentes, et des valeurs petites et regroupées pour les anciennes.</p>
<p>L&rsquo;expression centrale, simplifiée, ressemble à ça :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span>(
</span></span><span style="display:flex;"><span>  ln( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) )
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">/</span>
</span></span><span style="display:flex;"><span>  ( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">6000</span> )
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#f92672">*</span> ( <span style="color:#ae81ff">1</span> <span style="color:#f92672">/</span> (<span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">/</span> <span style="color:#ae81ff">1440</span>) )
</span></span><span style="display:flex;"><span><span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>
</span></span></code></pre></div><p>Soit <code>s</code> l&rsquo;âge en secondes. La formule est grossièrement <code>ln(s) / s * C</code>, où le logarithme au numérateur et <code>s</code> au dénominateur font diminuer le résultat rapidement à mesure que <code>s</code> augmente.</p>
<p>Converti en entier, l&rsquo;effet est le suivant : une révision sauvegardée il y a 10 minutes pourrait scorer 8432, une sauvegardée il y a 11 minutes score 8431. Elles sont dans des buckets différents. Une révision d&rsquo;il y a six mois score 2, une d&rsquo;il y a huit mois score aussi 2. Même bucket. La window function choisit ensuite la révision la plus récente de chaque bucket et supprime le reste.</p>
<p>Le résultat est automatique : les sauvegardes récentes sont toutes gardées parce que chacune a un score distinct ; les anciennes sont élagées parce que beaucoup partagent le même score.</p>
<h2 id="la-tentative-dql-qui-na-pas-abouti">La tentative DQL qui n&rsquo;a pas abouti</h2>
<p>Les window functions ne font pas partie de DQL. Le langage de requête de Doctrine n&rsquo;a pas de syntaxe pour <code>OVER</code>, <code>PARTITION BY</code> ou <code>ROW_NUMBER()</code>. Avant de passer au SQL brut, l&rsquo;équipe a essayé de les ajouter.</p>
<p>L&rsquo;approche <code>FunctionNode</code> fonctionne pour les fonctions SQL simples, comme on l&rsquo;avait déjà vu avec la FTS. Un nœud <code>RowNumber</code> émettant <code>ROW_NUMBER()</code> est trivial :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">RowNumber</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">FunctionNode</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;ROW_NUMBER()&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>La partie plus difficile est <code>OVER(PARTITION BY ... ORDER BY ...)</code>. Un nœud de fonction <code>Over</code> a été ébauché, avec un nœud AST <code>PartitionByClause</code> personnalisé pour gérer la clause <code>PARTITION BY</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Over</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">FunctionNode</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">PartitionByClause</span> $partitionByClause <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">OrderByClause</span> $orderByClause <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;OVER(&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">partitionByClause</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">?</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">partitionByClause</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">:</span> ($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">orderByClause</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">?</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">orderByClause</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;&#39;</span>))
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span><span style="color:#e6db74">&#39;)&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Ça n&rsquo;a jamais été terminé. Les classes ont été livrées marquées <code>@deprecated</code> et &ldquo;NOT TESTED YET&rdquo;. Le problème est la composabilité : <code>FunctionNode</code> de DQL fonctionne bien pour les fonctions qui apparaissent dans les clauses WHERE ou les expressions SELECT. Une window function comme <code>ROW_NUMBER() OVER (PARTITION BY ...)</code> est une structure différente : elle apparaît dans une position SELECT, modifie la sémantique de la requête englobante, et exige que le parseur gère <code>PARTITION BY</code> comme une extension de la grammaire DQL. Rendre ça suffisamment robuste pour être fiable en production est un investissement significatif. Passer à DBAL et écrire le SQL directement a pris un après-midi.</p>
<h2 id="la-requête-couche-par-couche">La requête, couche par couche</h2>
<p>L&rsquo;implémentation finale est trois requêtes imbriquées :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">FROM</span> revision
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> iri <span style="color:#f92672">=</span> <span style="color:#f92672">?</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> id <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">IN</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> id <span style="color:#66d9ef">FROM</span> (
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>            row_number() OVER (
</span></span><span style="display:flex;"><span>                PARTITION <span style="color:#66d9ef">BY</span> num, iri
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> num <span style="color:#66d9ef">DESC</span>, created_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span>            ) <span style="color:#66d9ef">AS</span> lines,
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">*</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">FROM</span> (
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>                (
</span></span><span style="display:flex;"><span>                    ( ln( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) )
</span></span><span style="display:flex;"><span>                      <span style="color:#f92672">/</span> ( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">6000</span> ) )
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">*</span> ( <span style="color:#ae81ff">1</span> <span style="color:#f92672">/</span> (<span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">/</span> <span style="color:#ae81ff">1440</span>) )
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>                )::numeric::integer <span style="color:#66d9ef">AS</span> num,
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">*</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">FROM</span> revision
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">WHERE</span> iri <span style="color:#f92672">=</span> <span style="color:#f92672">?</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> created_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span>        ) <span style="color:#66d9ef">AS</span> lst
</span></span><span style="display:flex;"><span>    ) <span style="color:#66d9ef">AS</span> rst
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> lines <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p><strong>Requête intérieure :</strong> calcule <code>num</code>, le score entier, pour chaque révision de l&rsquo;IRI donnée. Les lignes sont triées par <code>created_at DESC</code> à ce stade.</p>
<p><strong>Requête intermédiaire :</strong> exécute <code>ROW_NUMBER() OVER (PARTITION BY num, iri ORDER BY num DESC, created_at DESC)</code>. Dans chaque bucket de score (<code>num</code>), les révisions sont numérotées à partir de 1 dans l&rsquo;ordre décroissant d&rsquo;âge. La révision la plus récente de chaque bucket obtient <code>lines = 1</code>.</p>
<p><strong>Filtre extérieur :</strong> ne garde que les lignes <code>lines = 1</code>, une révision par bucket de score.</p>
<p><strong>DELETE :</strong> supprime chaque révision pour cet IRI qui n&rsquo;est pas dans l&rsquo;ensemble gardé.</p>
<p>Le <code>PARTITION BY num, iri</code> est redondant sur l&rsquo;IRI (toute la requête est déjà filtrée sur un IRI), mais rend l&rsquo;intention explicite et garde la logique correcte si la requête est un jour réutilisée dans un contexte plus large.</p>
<p>La méthode est appelée depuis une requête complémentaire qui identifie quels IRIs ont accumulé plus qu&rsquo;un seuil de révisions :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getIrisWithMoreRevisionThan</span>(<span style="color:#a6e22e">int</span> $maxRevisionsCount, <span style="color:#a6e22e">int</span> $limit <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>, <span style="color:#f92672">?</span><span style="color:#a6e22e">int</span> $retencyDay <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $queryBuilder <span style="color:#f92672">=</span> $this
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createQueryBuilder</span>(<span style="color:#e6db74">&#39;revision&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">select</span>(<span style="color:#e6db74">&#39;revision.iri&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">groupBy</span>(<span style="color:#e6db74">&#39;revision.iri&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">having</span>(<span style="color:#e6db74">&#39;COUNT(1) &gt; :maxRevisions&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">orderBy</span>(<span style="color:#e6db74">&#39;COUNT(1)&#39;</span>, <span style="color:#a6e22e">Order</span><span style="color:#f92672">::</span><span style="color:#a6e22e">Descending</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">value</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setParameter</span>(<span style="color:#e6db74">&#39;maxRevisions&#39;</span>, $maxRevisionsCount);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">array_column</span>($queryBuilder<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getQuery</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getResult</span>(), <span style="color:#e6db74">&#39;iri&#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Les deux méthodes tournent ensemble dans un nettoyage planifié : trouver les IRIs au-dessus du seuil, élaguer chacun.</p>
<h2 id="le-câbler-à-une-commande-planifiée">Le câbler à une commande planifiée</h2>
<p>La requête d&rsquo;élagage ne s&rsquo;exécute pas dans une requête HTTP. Elle tourne derrière une commande Symfony, appelée sur un planning.</p>
<p>La commande prend quelques options pour contrôler son agressivité :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsCommand(&#39;app:purge:revision&#39;, &#39;Remove useless revisions&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">PurgeRevisionCommand</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Command</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">configure</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $this
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;max-revisions&#39;</span>, <span style="color:#e6db74">&#39;m&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_REQUIRED</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Seuil de révisions au-dessus duquel un IRI est élaguée&#39;</span>, <span style="color:#ae81ff">30</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;limit&#39;</span>, <span style="color:#e6db74">&#39;l&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_REQUIRED</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Nombre max d\&#39;IRIs à traiter par exécution&#39;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;delay&#39;</span>, <span style="color:#e6db74">&#39;w&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_REQUIRED</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Délai en secondes entre chaque IRI&#39;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;retencyDay&#39;</span>, <span style="color:#e6db74">&#39;r&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_OPTIONAL</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Ne traiter que les IRIs dont la dernière révision est plus vieille que N jours&#39;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">execute</span>(<span style="color:#a6e22e">InputInterface</span> $input, <span style="color:#a6e22e">OutputInterface</span> $output)<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $iris <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">revisionRepository</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getIrisWithMoreRevisionThan</span>(
</span></span><span style="display:flex;"><span>            (<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;max-revisions&#39;</span>),
</span></span><span style="display:flex;"><span>            (<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;limit&#39;</span>),
</span></span><span style="display:flex;"><span>            (<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;retencyDay&#39;</span>),
</span></span><span style="display:flex;"><span>        );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> ($iris <span style="color:#66d9ef">as</span> $iri) {
</span></span><span style="display:flex;"><span>            $totalDeleted <span style="color:#f92672">+=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">revisionRepository</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">deleteOldRevisionForIri</span>($iri);
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">usleep</span>((<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;delay&#39;</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">1_000_000</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Command</span><span style="color:#f92672">::</span><span style="color:#a6e22e">SUCCESS</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>L&rsquo;option <code>--delay</code> mérite attention : sur une base de données chargée, marteler une centaine d&rsquo;instructions <code>DELETE</code> dos à dos peut provoquer de la contention de verrous. Un petit sleep entre les itérations empêche l&rsquo;élagage d&rsquo;entrer en concurrence avec le trafic de production.</p>
<p>La commande tourne derrière deux entrées crontab avec des seuils différents :</p>
<pre tabindex="0"><code># Horaire : garder 30 révisions par IRI, traiter 100 IRIs par exécution
0 * * * * php bin/console app:purge:revision --max-revisions 30 --limit 100

# Nocturne : pour le contenu non touché depuis un an, garder seulement 3
0 0 * * * php bin/console app:purge:revision --max-revisions 3 --limit 100 --retencyDay 365
</code></pre><p>La stratégie à deux niveaux est importante. Le job horaire garde 30 révisions par IRI, ce qui est un plafond raisonnable pour le contenu activement édité. Le job nocturne cible seulement les IRIs non mis à jour depuis plus d&rsquo;un an et n&rsquo;en garde que 3. Un article qui n&rsquo;a pas bougé depuis douze mois n&rsquo;a pas besoin de trente versions dans son historique.</p>
<h2 id="ce-que-ça-donne-en-pratique">Ce que ça donne en pratique</h2>
<p>Un article sauvegardé 200 fois gardera typiquement 20 à 30 révisions après élagage : la plupart des sauvegardes récentes, quelques-unes du mois dernier, une ou deux de chaque trimestre de l&rsquo;année précédente. Le décompte exact dépend de la distribution d&rsquo;âge des sauvegardes, pas d&rsquo;un plafond arbitraire.</p>
<p>Un article mis à jour pour la dernière fois il y a deux ans pourrait se retrouver avec 5 ou 6 révisions. Les modifications récentes sont toutes là ; l&rsquo;ancien historique est compressé mais pas effacé.</p>
<p>Ce n&rsquo;est pas un historique parfait. C&rsquo;est un historique utile.</p>
<h2 id="la-frontière-entre-dql-et-sql-brut">La frontière entre DQL et SQL brut</h2>
<p>La tentative window function n&rsquo;est pas un échec à cacher. C&rsquo;est une donnée utile : <code>FunctionNode</code> fonctionne bien pour les fonctions scalaires dans les positions WHERE et SELECT, mais composer une expression complète <code>ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)</code> en DQL est plus difficile qu&rsquo;il n&rsquo;y paraît. L&rsquo;extension de grammaire, les nœuds AST, l&rsquo;intégration du SQL walker : c&rsquo;est une quantité non triviale de code pour quelque chose que le SQL natif gère en trois lignes.</p>
<p>La frontière pratique est grossièrement celle-ci : si une fonctionnalité PostgreSQL correspond à un appel de fonction d&rsquo;arité fixe, le DQL personnalisé convient. Si elle nécessite une nouvelle syntaxe de clause (frames de fenêtre, CTEs, lateral joins), le DBAL natif est généralement le meilleur compromis.</p>
]]></content:encoded></item><item><title>Forcer l'UTC dans Doctrine sans toucher aux entités</title><link>https://guillaumedelre.github.io/fr/2017/02/19/forcer-lutc-dans-doctrine-sans-toucher-aux-entit%C3%A9s/</link><pubDate>Sun, 19 Feb 2017 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2017/02/19/forcer-lutc-dans-doctrine-sans-toucher-aux-entit%C3%A9s/</guid><description>Comment surcharger les types natifs de Doctrine pour imposer l&amp;#39;UTC partout, sans modifier une seule entité.</description><content:encoded><![CDATA[<p>Un timestamp qui revient de la base de données avec une heure de décalage. Pas à chaque fois. Uniquement quand le serveur de dev tourne en <code>Europe/Paris</code> et que la CI tourne en UTC. Le genre de bug qui disparaît quand on le cherche et qui revient en production un vendredi soir.</p>
<p>Le problème n&rsquo;est pas dans la logique métier. Il est dans ce que Doctrine fait discrètement avec les dates.</p>
<h2 id="ce-que-doctrine-fait-par-défaut">Ce que Doctrine fait par défaut</h2>
<p>Quand on déclare un champ <code>datetime</code> dans une entité Doctrine, la conversion entre PHP et la base de données passe par <code>DateTimeType</code>. Cette classe appelle <code>format()</code> sur l&rsquo;objet <code>DateTime</code> pour écrire en base, et <code>DateTime::createFromFormat()</code> pour le relire. Aucune mention de timezone nulle part.</p>
<p>Si l&rsquo;objet PHP est en <code>Europe/Paris</code>, Doctrine formate <code>2017-01-15 11:30:00</code> et l&rsquo;écrit tel quel. Si le serveur qui lit ce champ est en UTC, il obtient <code>2017-01-15 11:30:00</code> et l&rsquo;interprète comme UTC. Une heure s&rsquo;est évaporée dans l&rsquo;aller-retour, sans le moindre message d&rsquo;erreur.</p>
<p><a href="https://www.doctrine-project.org/projects/doctrine-orm/en/latest/cookbook/working-with-datetime.html" target="_blank" rel="noopener noreferrer">La doc Doctrine couvre ce sujet</a> et suggère des types personnalisés comme solution. Ce qu&rsquo;elle mentionne en passant, c&rsquo;est qu&rsquo;on peut donner à ces types personnalisés le même nom que les types natifs. Ce détail change tout.</p>
<h2 id="remplacer-pas-ajouter">Remplacer, pas ajouter</h2>
<p>La plupart des exemples de types Doctrine personnalisés introduisent un nouveau nom : <code>utc_datetime</code>, <code>app_date</code>, et ainsi de suite. On annote ensuite chaque champ avec <code>type: 'utc_datetime'</code> dans les entités. Ça fonctionne, mais c&rsquo;est fastidieux et ça ne protège pas contre un <code>type: 'datetime'</code> oublié.</p>
<p>L&rsquo;autre option : enregistrer le type personnalisé sous le nom <code>datetime</code>. Doctrine remplace sa propre implémentation par la nôtre, partout, sans exception. Chaque champ <code>datetime</code> de toutes les entités passe par notre logique, sans changer une seule annotation.</p>
<p>C&rsquo;est ce qu&rsquo;on vient de déployer sur notre plateforme de microservices PHP. Voici à quoi ça ressemble.</p>
<h2 id="le-trait-partagé">Le trait partagé</h2>
<p>Les deux types (<code>date</code> et <code>datetime</code>) partagent la même logique de conversion via un trait :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">trait</span> <span style="color:#a6e22e">UTCDate</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">\DateTimeZone</span> $utc;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToPHPValue</span>($value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">\DateTime</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">null</span> <span style="color:#f92672">===</span> $value <span style="color:#f92672">||</span> $value <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">\DateTime</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> $value;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $format <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getFormat</span>($platform);
</span></span><span style="display:flex;"><span>        $converted <span style="color:#f92672">=</span> <span style="color:#a6e22e">\DateTime</span><span style="color:#f92672">::</span><span style="color:#a6e22e">createFromFormat</span>($format, $value, $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getUtc</span>());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$converted) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\RuntimeException</span>(
</span></span><span style="display:flex;"><span>                <span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#39;Could not convert database value &#34;%s&#34; to DateTime using format &#34;%s&#34;.&#39;</span>, $value, $format)
</span></span><span style="display:flex;"><span>            );
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">postConvert</span>($converted);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $converted;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">abstract</span> <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getFormat</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getUtc</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">\DateTimeZone</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">empty</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">utc</span>)) {
</span></span><span style="display:flex;"><span>            $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">utc</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\DateTimeZone</span>(<span style="color:#e6db74">&#39;UTC&#39;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">utc</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Le point clé : <code>\DateTime::createFromFormat()</code> reçoit une timezone UTC explicite. La valeur brute issue de la base est interprétée comme UTC, peu importe la timezone configurée sur le serveur PHP.</p>
<h2 id="utcdatetimetype">UTCDateTimeType</h2>
<p>Pour les champs <code>datetime</code>, le chemin d&rsquo;écriture doit aussi imposer l&rsquo;UTC :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UTCDateTimeType</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">DateTimeType</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">use</span> <span style="color:#a6e22e">UTCDate</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToPHPValue</span>($value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">\DateTime</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">null</span> <span style="color:#f92672">===</span> $value <span style="color:#f92672">||</span> $value <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">\DateTimeInterface</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">convertToPHPValue</span>($value, $platform);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">convertToPHPValue</span>(<span style="color:#e6db74">&#34;</span><span style="color:#e6db74">$value</span><span style="color:#e6db74">+0000&#34;</span>, $platform);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToDatabaseValue</span>($value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($value <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">\DateTime</span>) {
</span></span><span style="display:flex;"><span>            $value<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setTimezone</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getUtc</span>());
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">convertToDatabaseValue</span>($value, $platform);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getFormat</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $platform<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getDateTimeFormatString</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">postConvert</span>(<span style="color:#a6e22e">\DateTime</span> $converted)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>En lecture (<code>convertToPHPValue</code>), si la valeur est une chaîne brute, on y ajoute <code>+0000</code> avant de déléguer au parent. Le parent utilise ensuite ce suffixe de timezone pour créer correctement l&rsquo;objet PHP.</p>
<p>En écriture (<code>convertToDatabaseValue</code>), on force le <code>DateTime</code> en UTC avant de le formater. Ce qui va en base est toujours en UTC.</p>
<h2 id="utcdatetype">UTCDateType</h2>
<p>Pour les colonnes <code>date</code> (sans composante horaire), même approche avec une étape supplémentaire :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UTCDateType</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">DateType</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">use</span> <span style="color:#a6e22e">UTCDate</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getFormat</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $platform<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getDateFormatString</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">postConvert</span>(<span style="color:#a6e22e">\DateTime</span> $converted)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $converted<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setTime</span>(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>La méthode <code>postConvert()</code> remet l&rsquo;heure à <code>00:00:00</code> après le parsing. Sans elle, un champ <code>date</code> pourrait revenir avec <code>23:59:59</code> ou <code>00:00:00+02:00</code> selon la timezone du serveur, ce qui casse les comparaisons et le tri.</p>
<h2 id="enregistrement-dans-symfony">Enregistrement dans Symfony</h2>
<p>La partie décisive : déclarer les types sous leurs noms natifs dans <code>config/packages/doctrine.yaml</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">doctrine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dbal</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">types</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">date</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">class</span>: <span style="color:#ae81ff">App\Doctrine\DBAL\Types\UTCDateType</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">datetime</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">class</span>: <span style="color:#ae81ff">App\Doctrine\DBAL\Types\UTCDateTimeType</span>
</span></span></code></pre></div><p>C&rsquo;est tout. Doctrine échange ses propres implémentations contre les nôtres. Les entités existantes ne changent pas, les migrations ne bougent pas, les annotations restent <code>type: Types::DATETIME_MUTABLE</code>. Le comportement change globalement, sans friction.</p>
<h2 id="12-microservices-89-colonnes-un-bloc-de-config">12 microservices, 89 colonnes, un bloc de config</h2>
<p>Ces deux types tournent maintenant sur 12 microservices indépendants, chacun avec sa propre config Doctrine, couvrant 89 colonnes de production. Les serveurs CI tournent en UTC, les machines de dev en <code>Europe/Paris</code>, les données voyagent entre eux sans dériver. Ce n&rsquo;est pas spectaculaire. C&rsquo;est juste fiable.</p>
<p>La vraie leçon n&rsquo;est pas technique : un problème de timezone non résolu est un problème d&rsquo;intégrité des données. Les décalages s&rsquo;accumulent silencieusement, les comparaisons se trompent, les exports deviennent inexacts. Deux lignes de config et trois classes peuvent prévenir ça définitivement.</p>
]]></content:encoded></item></channel></rss>