<?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/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Entity-Storage on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/entity-storage/</link><description>Recent content in Entity-Storage on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.168.0</generator><language>en-us</language><lastBuildDate>Sat, 26 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/entity-storage/feed.xml" rel="self" type="application/rss+xml"/><item><title>Fixing a hardcoded revision key in Waaseyaa's entity storage</title><link>https://jonesrussell.github.io/blog/entity-storage-hardcoded-revision-id/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/entity-storage-hardcoded-revision-id/</guid><category>php</category><category>waaseyaa</category><blog:tag>php</blog:tag><blog:tag>waaseyaa</blog:tag><blog:tag>entity-storage</blog:tag><blog:tag>testing</blog:tag><description>How Waaseyaa&amp;rsquo;s EntityRepository let entity types configure their revision-pointer column, but ten call sites still read the literal revision_id string, and how a single private accessor plus a two-way test closed the gap.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p><a href="https://github.com/waaseyaa/framework">Waaseyaa</a>&rsquo;s <code>packages/entity-storage</code> lets an entity type declare which column holds its &ldquo;current revision&rdquo; pointer instead of assuming the literal <code>revision_id</code> — resolved through <code>$entityType-&gt;getKeys()['revision'] ?? 'revision_id'</code>. <code>EntityRepository</code> is the class responsible for reading and writing that pointer across every revision operation: save, load, roll back, publish, prune. The lookup existed and worked correctly in <code>doSave()</code>. It just wasn&rsquo;t used anywhere else in the class. Here&rsquo;s the bug, the fix, and why &ldquo;the correct way to read this value exists somewhere in the file&rdquo; isn&rsquo;t the same as &ldquo;every call site uses it.&rdquo;</p>
<h2 id="the-bug-a-getter-nobody-called">The bug: a getter nobody called</h2>
<p><code>doSave()</code> resolved the configured key properly when it first wrote the base row&rsquo;s pointer:</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>$revisionKey <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityType</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getKeys</span>()[<span style="color:#e6db74">&#39;revision&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;revision_id&#39;</span>;
</span></span><span style="display:flex;"><span>$idKeyName <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityType</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getKeys</span>()[<span style="color:#e6db74">&#39;id&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;id&#39;</span>;
</span></span><span style="display:flex;"><span>$claimed <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">database</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">update</span>($entityTypeId)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">fields</span>([$revisionKey <span style="color:#f92672">=&gt;</span> $revisionId])
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span></code></pre></div><p>But every other method that later read that same base row back reached for the literal string instead. <code>loadWorkingCopy()</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>$baseRow <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">readDriverRow</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityType</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">id</span>(), $id);
</span></span><span style="display:flex;"><span>$baseRevisionId <span style="color:#f92672">=</span> $baseRow <span style="color:#f92672">!==</span> <span style="color:#66d9ef">null</span> <span style="color:#f92672">?</span> (<span style="color:#a6e22e">int</span>) ($baseRow[<span style="color:#e6db74">&#39;revision_id&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">:</span> <span style="color:#ae81ff">0</span>;
</span></span></code></pre></div><p><code>rollback()</code>, <code>setCurrentRevision()</code>, and <code>pruneRevisions()</code> had the same pattern — <code>(int) ($priorBaseRow['revision_id'] ?? 0)</code> — and <code>loadRevision()</code> wrote the hydrated row&rsquo;s pointer back under the same hardcoded key. For an entity type using the default column name, <code>revision_id</code> and the configured key were the same string, so none of this showed up.</p>
<p>Configure an entity type with a different revision column — the test that caught this uses <code>vid</code> — and the base row&rsquo;s pointer lives under <code>vid</code>. But <code>loadWorkingCopy()</code>, <code>rollback()</code>, <code>setCurrentRevision()</code>, and <code>pruneRevisions()</code> keep looking for <code>revision_id</code>, find nothing, and fall back to <code>?? 0</code>. A pointer read as revision 0 breaks every guard built on top of it:</p>
<ul>
<li>Is this the <strong>current revision</strong>?</li>
<li>Is this revision <strong>safe to prune</strong>?</li>
<li>What revision did we <strong>roll back</strong> from?</li>
</ul>
<p>All three quietly answer as if the entity had no revision history at all.</p>
<h2 id="the-fix-one-private-accessor-used-everywhere">The fix: one private accessor, used everywhere</h2>
<p>The fix adds a single method that&rsquo;s the only place <code>getKeys()['revision']</code> gets resolved:</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:#e6db74">/**
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> * Return the entity type&#39;s base-row revision pointer key.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> *
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> * Revision history tables use their own fixed `revision_id` column; this
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> * key applies only to entity values and the base storage row.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> */</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">revisionKey</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> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityType</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getKeys</span>()[<span style="color:#e6db74">&#39;revision&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;revision_id&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Every call site that previously repeated the <code>getKeys()['revision'] ?? 'revision_id'</code> expression, or worse, the bare <code>'revision_id'</code> literal, now calls <code>$this-&gt;revisionKey()</code> instead. <strong>Ten methods</strong> changed:</p>
<ul>
<li><code>doSave()</code></li>
<li><code>loadRevision()</code></li>
<li><code>loadWorkingCopy()</code></li>
<li><code>rollback()</code></li>
<li><code>setCurrentRevision()</code></li>
<li><code>doSetPublishedRevision()</code></li>
<li><code>loadTranslation()</code></li>
<li><code>hydrateTranslationRow()</code></li>
<li><code>pruneRevisions()</code></li>
<li><code>backfillInitialRevisions()</code></li>
</ul>
<p>A few spots needed one more line, not just a substitution:</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>$revisionKey <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">revisionKey</span>();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> ($revisionKey <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;revision_id&#39;</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">unset</span>($row[<span style="color:#e6db74">&#39;revision_id&#39;</span>]);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>$row[$revisionKey] <span style="color:#f92672">=</span> $revisionId;
</span></span></code></pre></div><p>That <code>unset()</code> matters wherever a row was already hydrated with a <code>revision_id</code> value from elsewhere (a revision history row, which does keep its own fixed <code>revision_id</code> column) before being written back onto the base table under a custom key — without it, a custom-key entity type would end up with both <code>revision_id</code> and <code>vid</code> sitting on the same row, one stale and one correct.</p>
<h2 id="verifying-it">Verifying it</h2>
<p>The new <code>EntityRepositoryConfiguredRevisionKeyTest</code> runs the same workflow twice through a data provider — once with the default <code>revision_id</code> key, once with a custom <code>vid</code> key — against a real SQLite-backed repository, not a mock:</p>
<ul>
<li>Create an entity and save a second revision, then assert the base row&rsquo;s pointer column (whichever one is configured) reads <strong>2</strong>.</li>
<li>Load revision 1 and the working copy, and assert their labels reflect the pre- and post-edit values.</li>
<li>Call <code>setCurrentRevision()</code> back to revision 1, and assert the base row&rsquo;s pointer drops back to <strong>1</strong>.</li>
<li>Roll back to revision 2, and assert the base row&rsquo;s pointer advances to the new tip revision <strong>3</strong>.</li>
<li>Promote a published revision, and assert both the revision pointer and <code>published_revision_id</code> land correctly on the base row.</li>
<li>Prune revisions under a keep-last-uniform policy, and assert the current, published, and latest revisions all survive regardless of which column tracks them.</li>
</ul>
<p>Every assertion reads the base row directly by column name, so the <code>vid</code> run of the data provider is the one that would have failed against the pre-fix code — the <code>revision_id</code> run passes either way, which is exactly why the bug was invisible until a non-default key was exercised.</p>
<h2 id="the-general-lesson">The general lesson</h2>
<p>A correct accessor sitting fifty lines from the last hardcoded copy of the same expression doesn&rsquo;t help you if nothing forces the rest of the class to use it. This bug shipped because the resolution logic — <code>getKeys()['revision'] ?? 'revision_id'</code> — was right the first time it was written, and every later call site independently retyped the fallback instead of calling the method that already existed for it. The fix is a one-line accessor; the more useful part is the test. A single data-provider case that runs the full revision workflow under both the default key and a deliberately different one is the only kind of test that can tell &ldquo;coincidentally correct because the two strings match&rdquo; apart from &ldquo;actually reads the configured key.&rdquo; If your class only has assertions written against the default configuration, you don&rsquo;t know it&rsquo;s configurable — you know it works once.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>