<?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>Materialized-Views on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/materialized-views/</link><description>Recent content in Materialized-Views on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/assets/og-mongreldb.png</url><link>https://www.mongreldb.com/assets/og-mongreldb.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Wed, 16 Sep 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/materialized-views/index.xml" rel="self" type="application/rss+xml"/><item><title>CTEs, Window Functions, and Materialized Views in Kit SQL</title><link>https://www.mongreldb.com/articles/2026/09/ctes-window-functions-and-materialized-views-in-kit-sql/</link><pubDate>Wed, 16 Sep 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/09/ctes-window-functions-and-materialized-views-in-kit-sql/</guid><description>MongrelDB Kit exposes recursive CTEs, window functions, CREATE TABLE AS SELECT, materialized views, and multi-statement execution through the same sqlRows surface, with recursive CTEs evaluated by MongrelDB itself and materialized view refreshes that publish atomically or not at all.</description><content:encoded><![CDATA[<p>The fastest way to find out whether a database is real is to ask it a reporting question, because the CRUD path is where every storage engine looks good and the reporting path is where the &ldquo;just use the query builder&rdquo; story collapses into the &ldquo;stand up a warehouse and a sync job&rdquo; story, and both of those stories are ways of admitting the thing you bought does not actually speak SQL. We wanted the answer to a reporting question in a MongrelDB app to be the same as the answer to any other query, which is: write the SQL, send it through <code>sqlRows</code>, get Arrow batches back, and go home, and the interesting engineering is not that the parser accepts these statements but what the engine does with the two hardest ones, recursive CTEs and materialized views, because those are precisely the features where &ldquo;supported&rdquo; usually hides a caveat the size of a second service you now have to run.</p>
<h2 id="recursive-ctes-evaluated-by-mongreldb-instead-of-datafusion">Recursive CTEs, evaluated by MongrelDB instead of DataFusion</h2>
<p>The query layer is DataFusion over Arrow record batches, and DataFusion v54&rsquo;s recursive CTE support is incomplete in a specific, dangerous way: it mis-handles column aliases on the base case, which means the query runs and the answer is wrong, and a wrong answer that looks right is the worst thing a database can hand you. Rather than ship that, the engine intercepts <code>WITH RECURSIVE</code> at the command dispatcher before DataFusion ever sees the statement and evaluates it with the standard semi-naive algorithm, which is: run the base case, register the result as a temp table named after the CTE, then iterate, evaluating the recursive arm against only the rows the previous round produced, the delta, rather than the whole accumulated table, and stop when a round comes back empty:</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">WITH</span> <span style="color:#66d9ef">RECURSIVE</span> subordinates(id, name, depth) <span style="color:#66d9ef">AS</span> (
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">SELECT</span> id, name, <span style="color:#ae81ff">0</span> <span style="color:#66d9ef">FROM</span> employees <span style="color:#66d9ef">WHERE</span> manager_id <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">UNION</span> <span style="color:#66d9ef">ALL</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">SELECT</span> e.id, e.name, s.depth <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">FROM</span> employees e <span style="color:#66d9ef">JOIN</span> subordinates s <span style="color:#66d9ef">ON</span> e.manager_id <span style="color:#f92672">=</span> s.id
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> id, name, depth <span style="color:#66d9ef">FROM</span> subordinates <span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> depth, id;
</span></span></code></pre></div><p>Delta-only evaluation is the detail that makes the feature honest, because evaluating each round against just the new rows instead of the full working set is what keeps the per-round cost proportional to progress rather than to history, and it is what the <code>UNION</code> versus <code>UNION ALL</code> distinction hangs on: with plain <code>UNION</code> the engine dedupes the accumulated set with a <code>SELECT DISTINCT</code> pass each round and stops as soon as a round adds nothing genuinely new, which means cyclic graphs terminate the way the standard says they should, while <code>UNION ALL</code> keeps bag semantics and simply accumulates each delta, so a truly cyclic walk under <code>UNION ALL</code> is bounded by a hard ceiling of ten thousand iterations with a cancellation checkpoint on every round, and then the outer query runs against whatever accumulated. The remaining limits are printed in the parser rather than discovered in production: exactly one statement per call, exactly one CTE in the <code>WITH</code> clause, and anything more exotic gets a clear error instead of a plausible-looking wrong result.</p>
<h2 id="window-functions-come-free-and-that-is-the-point">Window functions come free, and that is the point</h2>
<p><code>ROW_NUMBER()</code>, <code>SUM() OVER (...)</code>, the ranking family, all of it rides on DataFusion&rsquo;s window support directly with no interception, because DataFusion&rsquo;s window implementation is good and there was no reason to write our own, and this part of the &ldquo;how much real SQL do you get&rdquo; answer should feel almost disappointing, since the engineering decision was to not engineer. The query that used to be a self-join with a correlated subquery in the MySQL 5.6 years, top three per region with a running total, is one scan now:</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">SELECT</span> region, product, revenue,
</span></span><span style="display:flex;"><span>       ROW_NUMBER() OVER (PARTITION <span style="color:#66d9ef">BY</span> region <span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> revenue <span style="color:#66d9ef">DESC</span>) <span style="color:#66d9ef">AS</span> rank_in_region,
</span></span><span style="display:flex;"><span>       <span style="color:#66d9ef">SUM</span>(revenue) OVER (PARTITION <span style="color:#66d9ef">BY</span> region) <span style="color:#66d9ef">AS</span> region_total
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> sales
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> region, rank_in_region;
</span></span></code></pre></div><p>The window frame evaluates over Arrow batches the engine already has in flight, and because a window function is just another expression to the planner it composes with everything else in this post, so you can wrap it in a CTE, feed it into <code>CREATE TABLE AS SELECT</code>, or freeze it into a materialized view, and none of those combinations need special handling anywhere in the SDK.</p>
<h2 id="materialized-views-that-publish-or-do-not">Materialized views that publish or do not</h2>
<p><code>CREATE MATERIALIZED VIEW</code> stores the defining query in the catalog alongside a <code>last_refresh_epoch</code>, and <code>REFRESH MATERIALIZED VIEW name</code> re-runs it, and the refresh path is where the actual decisions live, because there are two of them and the engine picks between them by inspecting your definition. If the definition is a plain single-table <code>GROUP BY</code> with <code>COUNT</code> and <code>SUM</code> outputs and none of the clauses we deliberately do not maintain incrementally, no joins, no filter, no <code>HAVING</code>, no <code>DISTINCT</code>, no <code>LIMIT</code>, no nested CTE, the engine maintains the view incrementally against a checkpointed position in the commit log, falling back to a rebuild of the incremental state when the checkpoint cannot carry forward. Everything else takes the full rebuild path: run the defining query, stage the results into a shadow building table, and publish the swap atomically only when the whole refresh succeeded, because a failed refresh discards the shadow and leaves the old data exactly where it was, and that behavior is a test in the repo, not an aspiration in a README.</p>
<p>The guardrails are equally unglamorous and equally deliberate: <code>REFRESH</code> is rejected inside an explicit transaction, the name must be a single unqualified identifier, a definition that has drifted to return a different number of columns than the materialized schema fails the refresh instead of silently truncating, and per-batch byte limits keep a pathological rebuild from eating the process. The honest tradeoff is that refresh is on demand rather than continuous, so readers see the last published epoch until the new build finishes, and freshness is a cron line you already know how to write, which is the Postgres operating model transplanted into an engine that does not need a Postgres.</p>
<h2 id="ctas-and-multi-statement-batches-round-it-out">CTAS and multi-statement batches round it out</h2>
<p><code>CREATE TABLE AS SELECT</code> runs the inner query, infers the schema from the result columns, refuses to guess when the inferred primary key comes back NULL, and stages rows under the same byte limits and build-then-publish machinery the materialized view refresh uses, because building a table from a query and rebuilding a table from a query are the same problem and should have one implementation:</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-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">await</span> <span style="color:#a6e22e">db</span>.<span style="color:#a6e22e">sql</span>(<span style="color:#e6db74">`
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  CREATE TABLE sales_summary AS
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  SELECT region, COUNT(*) AS n, SUM(revenue) AS total
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  FROM sales
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  GROUP BY region;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">`</span>);
</span></span></code></pre></div><p>Multi-statement execution works the way you would want for fixture loads and one-shot setup scripts, with semicolon-separated statements in a single call, and the only subtlety is that the splitter runs before command dispatch and stays quiet when the first semicolon lives inside a <code>BEGIN ... END</code> block, because <code>CREATE TRIGGER</code> bodies contain semicolons of their own and a splitter that cannot tell a statement boundary from a trigger body is a bug waiting for a migration to find it:</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-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">rows</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">db</span>.<span style="color:#a6e22e">sqlRows</span>(<span style="color:#e6db74">`
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  INSERT INTO config VALUES (&#39;seed_version&#39;, &#39;3&#39;);
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  UPDATE stats SET dirty = 1;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  SELECT COUNT(*) AS n FROM stats;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">`</span>);
</span></span></code></pre></div><h2 id="what-this-buys-and-what-it-costs">What this buys and what it costs</h2>
<p>The MySQL-era answer to reporting questions was a second database, first a read replica with long queries pointed at it, later a warehouse with a pipeline, and both answers existed because the application database could not evaluate the query you actually needed to ask. The claim here is narrower and more useful than &ldquo;we support SQL&rdquo;: recursive CTEs are evaluated by an algorithm whose correctness you can audit in an afternoon, window functions are whatever DataFusion&rsquo;s sort-based evaluation costs and not a penny of custom code, materialized views refresh atomically or not at all with incremental maintenance for the count-and-sum cases that make up most real dashboards, and the same statement parses identically from TypeScript, Rust, Python, or the PHP client because there is one parser and one planner behind all four, which is the entire thesis of Kit compressed into a single sentence: the SDKs differ in syntax, never in semantics. The costs are printed rather than hidden, incremental maintenance covers <code>COUNT</code> and <code>SUM</code> on a single table today, recursive evaluation is delta-based rather than fully memoized, and refresh is pull-based rather than streaming, and we would rather you read those limits in an error message or a blog post than find them in a dashboard that went quietly stale.</p>
]]></content:encoded></item></channel></rss>