<?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>Postgresql on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/tags/postgresql/</link><description>Recent content in Postgresql on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Mon, 10 Feb 2025 00:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/tags/postgresql/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>