<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title><![CDATA[Andy Croll]]></title>
  <link href="https://andycroll.com/index.xml" rel="self"/>
  <link href="https://andycroll.com/"/>
  <updated>2026-11-10T09:47:07+00:00</updated>
  <id>https://andycroll.com/</id>
  <author>
    <name><![CDATA[Andy Croll]]></name>
    <email><![CDATA[andy@goodscary.com]]></email>
  </author>
  <generator uri="http://jekyllrb.com/">Jekyll</generator>

  
  <entry>
    <title type="html"><![CDATA[Homebridge and Pi-hole on a Raspberry Pi 4]]></title>
    <link href="https://andycroll.com/other/homebridge-and-pi-hole-on-a-raspberry-pi-4/"/>
    <updated>2026-10-31T09:00:00+00:00</updated>
    <id>https://andycroll.com/other/homebridge-and-pi-hole-on-a-raspberry-pi-4</id>
    <content type="html"><![CDATA[<p>I had a Raspberry Pi 4B I thought might be dead. It wasn’t. I’m having a bunch of building work done and of course, the thermostats do not work with Apple HomeKit.</p>

<p>So the Pi now runs <a href="https://homebridge.io">Homebridge</a>, which puts our underfloor heating thermostats (and also now <a href="https://github.com/tavicu/homebridge-samsung-tizen">Samsung TVs</a>) in Apple Home. For good measure it also runs <a href="https://pi-hole.net">Pi-hole</a>, which blocks ads and trackers for every device in the house.</p>

<h2 id="gear-required">Gear required</h2>

<ul>
  <li>A Raspberry Pi 4B + USB-C power + micro-HDMI</li>
  <li>A (new) microSD card</li>
  <li><a href="https://www.raspberrypi.com/software/">Raspberry Pi Imager</a> on my Mac</li>
  <li>An <a href="https://argon40.com">Argon ONE</a> case and a USB SSD</li>
</ul>

<h2 id="i-thought-the-pi-was-dead">I thought the Pi was dead</h2>

<p>First I checked the cheap parts. SD cards and power supplies fail far more often than the Pis themselves.</p>

<ol>
  <li>Removed and replaced the SD card.</li>
  <li>Used the official power supply. Apparently some early Pi 4 boards are buggy with arbitrary USB-C chargers and cables.</li>
  <li>Plugged into my TV using HDMI. The light was on but the TV stayed blank. That meant a <a href="https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#raspberry-pi-boot-eeprom">corrupt bootloader EEPROM</a>, which is allegedly common on a Pi 4.</li>
</ol>

<h3 id="bootloader-recovery">Bootloader recovery</h3>

<p>In Raspberry Pi Imager, chose <strong>Raspberry Pi 4</strong>, then <strong>Misc utility images → Bootloader (Pi 4 family) → SD Card Boot</strong>. Wrote it to the new SD card. Booted the Pi from it and waited 10 seconds. Screen went green!</p>

<h2 id="homebridge">Homebridge</h2>

<p>Flashed the <a href="https://github.com/homebridge/homebridge-raspbian-image">Homebridge Raspberry Pi image</a> with Raspberry Pi Imager. Set the username, password and Wi-Fi in Imager’s customisation step.</p>

<ol>
  <li>First boot took <strong>about 5 minutes</strong> to resize and reboot.</li>
  <li>Opened <code class="language-plaintext highlighter-rouge">http://&lt;ip&gt;:8581</code> on my Mac.</li>
  <li>Paired with Apple Home using the QR code on the Homebridge dashboard from the Home app: <strong>+ → Add Accessory</strong>. Needed to accept the “Uncertified Accessory” warning.</li>
</ol>

<p>Then, I <strong>reserved a fixed IP for the Pi</strong> on my router. Pi-hole needs this later, and it makes everything else easier to find. Fixing a bunch of IPs of home-based things is not a bad idea in my experience.</p>

<h2 id="prowarm-thermostats">ProWarm thermostats</h2>

<p>There’s no Homebridge plugin for <a href="https://prowarm.com">ProWarm</a> (!) and the regular app can’t talk to anything. However, ProWarm’s app is a rebranded <a href="https://platform.tuya.com">Tuya</a> app.</p>

<p>So the route is ProWarm → Tuya’s own <strong>Smart Life</strong> app → <a href="https://github.com/0x5e/homebridge-tuya-platform">Homebridge</a>.</p>

<p>I won’t go into the details here: an agent can walk you through the steps, and the thermostats might get replaced anyway if they become more trouble than they’re worth.</p>

<p>The most important note is to <strong>keep the schedules on the thermostat</strong>, not in the Home app.</p>

<ul>
  <li><strong>Fewer things can break.</strong> The thermostat’s schedule runs with no network at all. A Home app schedule needs a home hub, the Pi, Wi-Fi and Tuya’s cloud all working at the same moment.</li>
  <li><strong>Two schedules fight each other.</strong> A Home app schedule and the thermostat’s own schedule keep undoing each other.</li>
  <li><strong>Learning mode needs the thermostat’s schedule.</strong> It starts heating early so the floor is warm at the set time. Underfloor heating takes hours to warm up, so this matters.</li>
</ul>

<p>I use the Home app for temporary overrides, and for “when everyone leaves, set to 16°C” automations. That kind of change only lasts until the next schedule block. If we’re away, we use the thermostat’s “Holiday Mode”.</p>

<p>Schedules, boost, holiday mode and frost protection all run on the thermostat itself. They keep working if the Pi dies. Homebridge is only for info and temporary overrides.</p>

<h2 id="pi-hole">Pi-hole</h2>

<p>A word of warning before you start: once your router points at Pi-hole, every device in the house depends on this Pi to look up websites. <em>I got this wrong the first time and the whole house lost Wi-Fi.</em></p>

<p>Also if the Pi dies, nothing loads, which is why I bought the case detailed later to auto-power the Pi. If I ever need to undo it, I’ll set the router’s DNS back to automatic.</p>

<h3 id="getting-a-terminal">Getting a terminal</h3>

<p>The Homebridge UI has a terminal: <strong>top-right menu → Terminal</strong>. But it logs you in as the <code class="language-plaintext highlighter-rouge">homebridge</code> service user. Plain <code class="language-plaintext highlighter-rouge">sudo</code> fails. So I had to switch to my own user (set up in Imager):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>su - &lt;username&gt;
</code></pre></div></div>

<p>SSH from the Mac should also work in theory, but I used the Homebridge terminal.</p>

<h3 id="install">Install</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sSL</span> https://install.pi-hole.net | bash
</code></pre></div></div>

<ul>
  <li>For upstream DNS, I chose <a href="https://one.one.one.one/family/">Cloudflare’s Family version of 1.1.1.1</a>.</li>
  <li>Accepted the defaults for blocklist, query logging and privacy level.</li>
</ul>

<p>Then I set the admin password. This is a separate password, for the Pi-hole web page only:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>pihole setpassword
</code></pre></div></div>

<h3 id="port-80-is-taken-for-admin-dashboard">Port 80 is taken for admin dashboard</h3>

<p>The Homebridge image runs nginx on port 80, so <code class="language-plaintext highlighter-rouge">http://&lt;ip&gt;/admin</code> showed Homebridge, not Pi-hole. Pi-hole’s DNS was fine; only the admin dashboard couldn’t get the port. So… moved it to 8080:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>pihole-FTL <span class="nt">--config</span> webserver.port <span class="s1">'8080o,[::]:8080o'</span>
<span class="nb">sudo </span>systemctl restart pihole-FTL
</code></pre></div></div>

<p>The admin page is now at <code class="language-plaintext highlighter-rouge">http://&lt;ip&gt;:8080/admin</code>.</p>

<h3 id="pointed-my-house-at-it">Pointed my house at it</h3>

<p>First I tested on my Mac. In <strong>System Settings → Network → Wi-Fi → Details → DNS</strong>, I added the Pi’s IP and removed the other entries. Then I browsed a few sites and watched them appear in Pi-hole’s <strong>Query Log</strong>.</p>

<h4 id="i-have-wildly-over-specced-in-house-wifi">I have wildly over-specced in-house WiFi</h4>

<p>I regret nothing about the levels of Cat6 and the number of UniFi Access Points I own.</p>

<p>My network has a Virgin Media Hub with a <a href="https://uk.store.ui.com/uk/en/products/ux">UniFi Express</a> behind it, and the UniFi does the routing. Virgin’s Hub won’t change the DNS it hands out, but UniFi will, and my entire house points at the UniFi.</p>

<ol>
  <li><strong>Set the DNS</strong>: <strong>Settings → Networks → Default</strong>, then the <strong>DHCP</strong> section, then <strong>DNS Server</strong>. Turned off <strong>Auto</strong>, entered the Pi’s IP, and left the second box empty. A secondary DNS server gives devices a way round Pi-hole, so I left it blank. The menu moves between UniFi versions, so look for “DHCP DNS Server”.</li>
  <li><strong>Left the Internet/WAN DNS setting alone.</strong> That’s UniFi’s own upstream DNS, not what devices get.</li>
  <li><strong>Turned off IPv6</strong> on that network. Otherwise devices can get Virgin’s IPv6 DNS server and skip Pi-hole.</li>
  <li><strong>Reconnected devices</strong> by turning Wi-Fi off and on. Or wait for their leases to renew.</li>
</ol>

<p>Devices plugged directly into the Virgin Hub, rather than into the UniFi, won’t use Pi-hole.</p>

<p><strong>Checked it worked.</strong> Removed the Pi’s IP from the Mac’s manual DNS settings, so the list was empty. Then turned Wi-Fi off and on and ran:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scutil <span class="nt">--dns</span> | <span class="nb">grep </span>nameserver
</code></pre></div></div>

<p>It listed only the Pi’s IP. That proves UniFi is handing it out. If you skip removing the manual entry, the check only shows the setting you typed in yourself.</p>

<p>To undo, set DNS Server back to <strong>Auto</strong>.</p>

<h3 id="blocklists">Blocklists</h3>

<p>Pi-hole ships with Steven Black’s list. I added:</p>

<ul>
  <li><strong><a href="https://github.com/hagezi/dns-blocklists">HaGeZi Pro</a></strong>, which blocks a lot but breaks little:
<code class="language-plaintext highlighter-rouge">https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/adblock/pro.txt</code></li>
  <li><strong>HaGeZi Threat Intelligence Feeds, Medium</strong>, for malware and phishing domains (an add-on):
<code class="language-plaintext highlighter-rouge">https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/adblock/tif.medium.txt</code></li>
  <li><strong>AdGuard Smart TV</strong>, for Samsung TV ads and tracking:
<code class="language-plaintext highlighter-rouge">https://adguardteam.github.io/HostlistsRegistry/assets/filter_7.txt</code></li>
  <li><strong>HaGeZi referral allowlist</strong>, added as an <strong>Allow</strong> list. It stops the Pro list breaking shopping and affiliate links:
<code class="language-plaintext highlighter-rouge">https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/whitelist-referral-native.txt</code></li>
</ul>

<p>Added each one under <strong>Lists</strong>, then ran <strong>Tools → Update Gravity</strong>.</p>

<p>I kept the default Steven Black list too, because Pi-hole removes duplicate domains when it builds the lists.</p>

<h4 id="why-not-a-big-list-of-lists">Why not a big list of lists?</h4>

<p>I looked at <a href="https://github.com/stevejenkins/pi-hole-lists">Steve Jenkins’ Pi-hole list of lists</a>. It’s 21 blocklists, including the ones I added. His regex file allows things for his household (Hulu, USPS, Roku, CrossFit), and blocks patterns like <code class="language-plaintext highlighter-rouge">^analytics</code>, <code class="language-plaintext highlighter-rouge">^stats</code> and <code class="language-plaintext highlighter-rouge">^aff</code> that might break legitimate sites. I want to minimise “home tech support”, so I took the Smart TV list and the referral allowlist and left the rest.</p>

<p>When something breaks, usually a shopping link or an email click-tracker, I find the blocked domain in the <strong>Query Log</strong> and click <strong>Allow</strong>.</p>

<h2 id="up-to-date">Up to date</h2>

<p>I don’t want to manage this actively.</p>

<p>Blocklists already update weekly on their own. Pi-hole runs the job from cron.</p>

<h3 id="os-security-updates">OS security updates</h3>

<p>Turned on <a href="https://wiki.debian.org/UnattendedUpgrades">unattended upgrades</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> unattended-upgrades
<span class="nb">sudo </span>dpkg-reconfigure <span class="nt">-plow</span> unattended-upgrades
</code></pre></div></div>

<h3 id="daily-blocklist-refresh-optional">Daily blocklist refresh (optional)</h3>

<p>HaGeZi updates its lists several times a day. Pi-hole only fetches them weekly. So I fetch them every day:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s1">'15 5 * * * root PATH="$PATH:/usr/local/bin" pihole -g &gt;/var/log/pihole/gravity-daily.log 2&gt;&amp;1'</span> <span class="se">\</span>
  | <span class="nb">sudo tee</span> /etc/cron.d/pihole-gravity-daily
</code></pre></div></div>

<h3 id="pi-hole-monthly">Pi-hole, monthly</h3>

<p>The manual update is:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pihole <span class="nt">-up</span>
</code></pre></div></div>

<p>I run it from cron at 4:30am on the 1st of each month:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s1">'30 4 1 * * root PATH="$PATH:/usr/local/bin" pihole -up &gt;/var/log/pihole/autoupdate.log 2&gt;&amp;1'</span> <span class="se">\</span>
  | <span class="nb">sudo tee</span> /etc/cron.d/pihole-autoupdate
</code></pre></div></div>

<h3 id="homebridge-plugins">Homebridge plugins</h3>

<p>Installed <strong><a href="https://github.com/homebridge-plugins/homebridge-updater">Homebridge Updater</a></strong> (<code class="language-plaintext highlighter-rouge">@homebridge-plugins/homebridge-updater</code>). It used to be called Homebridge Plugin Update Check. Every auto-update option is off by default.</p>

<p>Turned on:</p>

<ul>
  <li><strong>autoUpdatePlugins</strong></li>
  <li><strong>autoRestartAfterUpdates</strong></li>
  <li><strong>autoUpdateSkipMajorVersions</strong>. Minor fixes install on their own and major versions wait. Seems sensible.</li>
</ul>

<p>Set <strong>Sensor Type</strong> to <strong>Contact Sensor</strong> and turned on notifications for it in the Home app. It tells me when updates are waiting, and when an automatic one fails.</p>

<h2 id="the-argon-one-case">The Argon ONE case</h2>

<p>Once it was all running, I moved the Pi into <a href="https://thepihut.com/products/argon-one-m-2-raspberry-pi-4-case">a fancy case</a>. It restarts after power loss, cools the board and attaches an SSD through USB.</p>

<p>Had to run Argon’s script as my own user, using <code class="language-plaintext highlighter-rouge">su</code> as above in the Homebridge terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://download.argon40.com/argon1.sh | bash
</code></pre></div></div>

<p>Now the power button works.</p>

<h2 id="move-to-a-usb-ssd">Move to a USB SSD</h2>

<p>SD cards wear out under constant writes, and Pi-hole writes logs all day. So I cloned the SD card onto the SSD.</p>

<h3 id="backed-up-first">Backed up first</h3>

<p>Backed up Homebridge and Pi-hole.</p>

<ul>
  <li>Homebridge UI → <strong>Settings → Backup</strong>.</li>
  <li>Pi-hole UI → <strong>Settings → Teleporter → Export</strong>.</li>
</ul>

<h3 id="clone">Clone</h3>

<p>Updated the bootloader:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>rpi-eeprom-update <span class="nt">-a</span>
</code></pre></div></div>

<p>Installed <a href="https://github.com/geerlingguy/rpi-clone">Jeff Geerling’s maintained fork of rpi-clone</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://raw.githubusercontent.com/geerlingguy/rpi-clone/master/install | <span class="nb">sudo </span>bash
</code></pre></div></div>

<p>Cloned, answering <strong>yes</strong> to the prompts:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>rpi-clone sda
</code></pre></div></div>

<h3 id="switched-over">Switched over</h3>

<p>Set USB boot first: <code class="language-plaintext highlighter-rouge">sudo raspi-config</code> → <strong>Advanced Options → Boot Order → USB Boot</strong>.</p>

<p>Powered off. Took out the SD card and pressed the power button. Ran <code class="language-plaintext highlighter-rouge">findmnt /</code> to check it showed <code class="language-plaintext highlighter-rouge">/dev/sda2</code>.</p>

<h2 id="wired-up">Wired up</h2>

<p>I’ve now plugged the Pi directly into the UniFi router and turned off Wi-Fi, reserving the IP address of the wired connection and using that as the DNS server.</p>
]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[Escape LIKE Wildcards with sanitize_sql_like]]></title>
    <link href="https://andycroll.com/ruby/escape-like-wildcards-with-sanitize-sql-like/"/>
    <updated>2026-10-12T02:00:00+00:00</updated>
    <id>https://andycroll.com/ruby/escape-like-wildcards-with-sanitize-sql-like</id>
    <content type="html"><![CDATA[<p>When you add a search box backed by a <code class="language-plaintext highlighter-rouge">LIKE</code> or <code class="language-plaintext highlighter-rouge">ILIKE</code> query, user input can change the pattern. A search for <code class="language-plaintext highlighter-rouge">100%</code> returns every row containing <code class="language-plaintext highlighter-rouge">100</code>. A bare <code class="language-plaintext highlighter-rouge">%</code> returns the whole table. Active Record’s <a href="https://api.rubyonrails.org/classes/ActiveRecord/Sanitization/ClassMethods.html#method-i-sanitize_sql_like"><code class="language-plaintext highlighter-rouge">sanitize_sql_like</code></a> fixes this.</p>

<h2 id="instead-of">Instead of…</h2>

<p>…dropping the user’s term straight into the pattern:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Article</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">scope</span> <span class="ss">:containing_text</span><span class="p">,</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">query</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">where</span><span class="p">(</span><span class="s2">"articles.title ILIKE :q"</span><span class="p">,</span> <span class="ss">q: </span><span class="s2">"%</span><span class="si">#{</span><span class="n">query</span><span class="si">}</span><span class="s2">%"</span><span class="p">)</span>
  <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Article</span><span class="p">.</span><span class="nf">containing_text</span><span class="p">(</span><span class="s2">"100%"</span><span class="p">)</span>        <span class="c1"># also matches "100 days of Rails"</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">containing_text</span><span class="p">(</span><span class="s2">"snake_case"</span><span class="p">)</span>  <span class="c1"># also matches "snake-case vs kebab-case"</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">containing_text</span><span class="p">(</span><span class="s2">"%"</span><span class="p">)</span>           <span class="c1"># matches everything</span>
</code></pre></div></div>

<h2 id="use">Use…</h2>

<p>…<code class="language-plaintext highlighter-rouge">sanitize_sql_like</code> around the term, before you wrap it in your own wildcards:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Article</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">scope</span> <span class="ss">:containing_text</span><span class="p">,</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">query</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">where</span><span class="p">(</span><span class="s2">"articles.title ILIKE :q"</span><span class="p">,</span> <span class="ss">q: </span><span class="s2">"%</span><span class="si">#{</span><span class="n">sanitize_sql_like</span><span class="p">(</span><span class="n">query</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span><span class="si">}</span><span class="s2">%"</span><span class="p">)</span>
  <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Article</span><span class="p">.</span><span class="nf">containing_text</span><span class="p">(</span><span class="s2">"100%"</span><span class="p">)</span>        <span class="c1"># "Reaching 100% coverage", not "100 days of Rails"</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">containing_text</span><span class="p">(</span><span class="s2">"snake_case"</span><span class="p">)</span>  <span class="c1"># "Why snake_case wins", not "snake-case vs kebab-case"</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">containing_text</span><span class="p">(</span><span class="s2">"%"</span><span class="p">)</span>           <span class="c1"># titles with a literal %, not everything</span>
</code></pre></div></div>

<h2 id="why">Why?</h2>

<p><code class="language-plaintext highlighter-rouge">LIKE</code> has three special characters: <code class="language-plaintext highlighter-rouge">%</code> matches any run of characters, <code class="language-plaintext highlighter-rouge">_</code> matches exactly one, and <code class="language-plaintext highlighter-rouge">\</code> escapes the other two. A bind parameter protects the SQL statement, but it passes these characters through untouched.</p>

<p><code class="language-plaintext highlighter-rouge">sanitize_sql_like</code> puts a backslash in front of each <code class="language-plaintext highlighter-rouge">%</code>, <code class="language-plaintext highlighter-rouge">_</code> and <code class="language-plaintext highlighter-rouge">\</code>, so they match literally:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Article</span><span class="p">.</span><span class="nf">sanitize_sql_like</span><span class="p">(</span><span class="s2">"100%"</span><span class="p">)</span>        <span class="c1">#=&gt; "100\\%"</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">sanitize_sql_like</span><span class="p">(</span><span class="s2">"snake_case"</span><span class="p">)</span>  <span class="c1">#=&gt; "snake\\_case"</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">sanitize_sql_like</span><span class="p">(</span><span class="s2">"c:</span><span class="se">\\</span><span class="s2">tmp"</span><span class="p">)</span>     <span class="c1">#=&gt; "c:\\\\tmp"</span>
</code></pre></div></div>

<p>Postgres and MySQL use <code class="language-plaintext highlighter-rouge">\</code> as the escape character by default. SQLite has no default, so add an <code class="language-plaintext highlighter-rouge">ESCAPE</code> clause and pass the same character to <code class="language-plaintext highlighter-rouge">sanitize_sql_like</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">where</span><span class="p">(</span><span class="s2">"articles.title LIKE :q ESCAPE '!'"</span><span class="p">,</span> <span class="ss">q: </span><span class="s2">"%</span><span class="si">#{</span><span class="n">sanitize_sql_like</span><span class="p">(</span><span class="n">query</span><span class="p">.</span><span class="nf">to_s</span><span class="p">,</span> <span class="s2">"!"</span><span class="p">)</span><span class="si">}</span><span class="s2">%"</span><span class="p">)</span>
</code></pre></div></div>

<p>Call <code class="language-plaintext highlighter-rouge">.to_s</code> on the input. <code class="language-plaintext highlighter-rouge">sanitize_sql_like</code> calls string methods on its argument, so it raises <code class="language-plaintext highlighter-rouge">NoMethodError</code> on <code class="language-plaintext highlighter-rouge">nil</code>. A blank search then becomes <code class="language-plaintext highlighter-rouge">%%</code> and matches every row. To return no results instead:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">scope</span> <span class="ss">:containing_text</span><span class="p">,</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">query</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="n">none</span> <span class="k">if</span> <span class="n">query</span><span class="p">.</span><span class="nf">blank?</span>

  <span class="n">where</span><span class="p">(</span><span class="s2">"articles.title ILIKE :q"</span><span class="p">,</span> <span class="ss">q: </span><span class="s2">"%</span><span class="si">#{</span><span class="n">sanitize_sql_like</span><span class="p">(</span><span class="n">query</span><span class="p">)</span><span class="si">}</span><span class="s2">%"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Escape the term <em>before</em> adding your own wildcards. <code class="language-plaintext highlighter-rouge">"%#{sanitize_sql_like(query)}%"</code> keeps the outer <code class="language-plaintext highlighter-rouge">%</code> as wildcards and escapes the inner ones. <code class="language-plaintext highlighter-rouge">sanitize_sql_like("%#{query}%")</code> escapes your wildcards too, so it only finds titles that contain a literal <code class="language-plaintext highlighter-rouge">%</code> either side of the term.</p>

<p>This method has been public API since Rails 5.2. Use it alongside the bind parameters from <a href="/ruby/really-really-really-do-not-interpolate-strings-into-active-record-methods/">not interpolating strings into Active Record</a>. Search your app for <code class="language-plaintext highlighter-rouge">LIKE</code> and <code class="language-plaintext highlighter-rouge">ILIKE</code> to find the queries that need it.</p>

<h2 id="why-not">Why not?</h2>

<p>If no user input reaches the pattern, you don’t need it. A hardcoded <code class="language-plaintext highlighter-rouge">where("path LIKE 'admin/%'")</code> is fine.</p>

<p>Some tools let users type <code class="language-plaintext highlighter-rouge">%</code> on purpose, such as an internal admin search. Escaping breaks that. Tell users in the UI whether wildcards work.</p>

<p>A leading <code class="language-plaintext highlighter-rouge">%</code> stops the database using a normal index, so every search scans the whole table. On Postgres, a <code class="language-plaintext highlighter-rouge">pg_trgm</code> GIN index on the column can help.</p>

<p><code class="language-plaintext highlighter-rouge">sanitize_sql_like</code> is for <code class="language-plaintext highlighter-rouge">LIKE</code> and <code class="language-plaintext highlighter-rouge">ILIKE</code> only. Postgres full-text search with <code class="language-plaintext highlighter-rouge">to_tsquery</code> has its own syntax to sanitize, and <code class="language-plaintext highlighter-rouge">pg_search</code> handles that for you.</p>

<h2 id="alternative-sqlite-escaping">Alternative SQLite escaping</h2>

<p>Arel’s <code class="language-plaintext highlighter-rouge">matches</code> can also write the <code class="language-plaintext highlighter-rouge">ESCAPE</code> clause for you:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">where</span><span class="p">(</span><span class="n">arel_table</span><span class="p">[</span><span class="ss">:title</span><span class="p">].</span><span class="nf">matches</span><span class="p">(</span><span class="s2">"%</span><span class="si">#{</span><span class="n">sanitize_sql_like</span><span class="p">(</span><span class="n">query</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span><span class="si">}</span><span class="s2">%"</span><span class="p">,</span> <span class="s2">"</span><span class="se">\\</span><span class="s2">"</span><span class="p">))</span>
</code></pre></div></div>
]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[Look at all the things he's not doing]]></title>
    <link href="https://andycroll.com/ruby/look-at-all-the-things-he-is-not-doing/"/>
    <updated>2026-10-02T09:00:00+00:00</updated>
    <id>https://andycroll.com/ruby/look-at-all-the-things-he-is-not-doing</id>
    <content type="html"><![CDATA[<p>There’s a moment in the early seasons of South Park when Cartman et al find the source of an underwear crisis in their mountain town. It’s the <a href="https://en.wikipedia.org/wiki/Gnomes_(South_Park)">underpants gnomes</a> in their cave. They have a plan.</p>

<ol>
  <li>Collect Underpants</li>
  <li>???</li>
  <li>Profit</li>
</ol>

<p>I’ve been considering <a href="https://youtu.be/vDjW_dRyKXY">David’s opening Rails World keynote</a> and realised that this is the main problem I have with it. Rails World is about “shaping the future of Ruby on Rails”. Instead what we got was opinionmaxxing: a lot of certainty about the destination, but <a href="https://youtu.be/vDjW_dRyKXY?t=2761">in his own words</a>, not much that was ‘practical’ or ‘prescriptive’ about getting there.</p>

<ol>
  <li>The model got good at writing code</li>
  <li>???</li>
  <li>Models write all code, we don’t need to care</li>
</ol>

<p>There’s a vibration of uncertainty in our community and the wider industry, even from folks, like me, who were early to playing with and then using the new LLM-powered coding tools and harnesses for real production work.</p>

<p>I was left with a “what’s the point?” feeling. I don’t believe this was the intention. I think David meant it as a provocation, to jolt the room. But the room was already there: when he <a href="https://youtu.be/vDjW_dRyKXY?t=1308">polled the audience</a> only <em>five</em> people out of 1,200 were still writing material amounts of code by hand. He seemed surprised at the scale of that. He shouldn’t have been, the Rails community are tinkerers, early adopters and pragmatists.</p>

<p>To be clear I think he’s directionally right on software. The way we make software has changed forever. Yesterday I shipped (and reviewed) 21 bug fix PRs to CoverageBook, upgraded a Rails app from 7.x and Tailwind 2 to latest versions and shipped a couple of feature PRs. I’m engaging more judgement on how much I code review when I can verify in other ways. I’ve never been so able to deliver well-made software, so fast.</p>

<p>I do believe however there’s a lot of interesting work to do in step 2. Software engineering has always been more than typing code. I’m not seeing evidence I can abdicate my taste to Anthropic, OpenAI or the fast-following open-weight models.</p>

<h2 id="elsewhere">Elsewhere</h2>

<p>There were multiple version numbers mentioned in the keynote: all LLMs, not a single mention of Rails 8.2.</p>

<p>David’s attention is elsewhere. Ruby was <a href="https://youtu.be/vDjW_dRyKXY?t=2205">“about 3%”</a> of his work this year, and he’s <a href="https://youtu.be/vDjW_dRyKXY?t=2892">“talked about nothing else but Omarchy for about 3 months.”</a> Now the models can one-shot a calculator and convert existing applications to Rust. He seems uninterested in the useful guardrails, determinism and token efficiency<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> that a battle-tested framework can provide when using agents.</p>

<p>He did concede, <a href="https://youtu.be/IEOqgPfGLQ8?t=3510">on the panel the next day</a>, that he’d still spin up Rails apps for new web software. And in the keynote he admitted the designers’ vibe-coded work on Basecamp 5 left the architecture <a href="https://youtu.be/vDjW_dRyKXY?t=1370">“a little like Swiss cheese”</a>, and they went back to manual review.</p>

<p>Problem is, the rest of us need to work now, and the next model might not be perfect. The Rails Foundation’s own <a href="https://rubyonrails.org/2026/9/9/agents-on-rails-stage-2">Agents on Rails benchmark</a> says it isn’t there yet: the best result on real feature tickets is 53.3%<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>, and most models score 15–35%. The timeline is unclear. Of course, if the next model removes the need for anything but machine code, this is all moot. But we’re in the aftermath of one tipping point, not past a second, greater one.</p>

<p>Rails’s opinions used to be all about step 2. The keynote put all the certainty on the destination and handed the path to the next model release. That’s the only plan for step 2: <a href="https://youtu.be/vDjW_dRyKXY?t=1395">“give it freaking 5 minutes.”</a> Robby Russell <a href="https://youtu.be/XXjTdyhml0c?t=2845">reflected in his keynote</a> that folks need “somebody willing to say, ‘We’ve learned enough from these experiments. We’re doing it this way now.’”</p>

<h2 id="aim--blast-radius">Aim &amp; Blast Radius</h2>

<p>Jeremy Daer, moderating the <a href="https://youtu.be/IEOqgPfGLQ8">fireside chat between Matz &amp; David</a> <a href="https://youtu.be/IEOqgPfGLQ8?t=1754">asked whether words like “slop” and “meat proxies” were diagnoses or coping mechanisms</a>. David said <a href="https://youtu.be/IEOqgPfGLQ8?t=1785">“90% cope”</a> and that <a href="https://youtu.be/IEOqgPfGLQ8?t=1896">“even the slop grenade is not long for this world.”</a> Jeremy pushed back:</p>

<p><a href="https://youtu.be/IEOqgPfGLQ8?t=1905">“Slop plus the delivery… when it’s being dumped on them”</a>.</p>

<p>That made me reconsider the other elements of that metaphor that make it really sing: the inaccurate trajectory, lobbed and not watched, the implication of a timer and the difference in experience of the thrower and the receiver.</p>

<p>David said <a href="https://youtu.be/IEOqgPfGLQ8?t=1938">“I don’t mind slop grenades at all… I don’t have to catch”</a>. That’s true for him, both in open source (you can refuse a drive-by PR) and at work (he’s in charge).</p>

<p>That’s not what slop grenades end up being for the rest of us though. Most of us can’t ignore PRs from our co-workers or a 3am page from the explosion of a slop-enabled production incident. It’s a thrower’s perspective. We have to catch.</p>

<p>If the models emulate human intelligence with machine determination why wouldn’t having higher level, verifiable, abstractions be useful? That’s why you can spin up a calculator or a Rust duplicate of an existing Rails app. The irresistible force of the agent has something to check its homework on: a bunch of industry standards, a test suite, an existing implementation. Aaron Patterson <a href="https://youtu.be/t0knYnEYBRo?t=3600">made this case</a> from a lower level:</p>

<p>“I’ve heard the argument that AI is just our next compiler… [you don’t read its output] because the compiler made a promise to you… your AI has not made this promise. There is no as-if rule for your English.”</p>

<p>Step 2’s hard part is that the model can’t write your spec. The published failure mode in the foundation’s own AI research is hand-rolling. <a href="https://youtu.be/IEOqgPfGLQ8?t=1515">Matz said it</a>: “we know how to define the goal for the agents… we have to formalize them and teach to the following generations.”</p>

<p>Clarity of aim turns a careless lob into a directed pass. I <a href="https://github.com/andycroll/readability-rb">ported readability</a> from JS to Ruby overnight - its test suite was the guard.<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup></p>

<p>How is David’s Rust-based HEY backend tested? How far along is it? If it’s checked against anything, the best spec is the Rails app it replaces. The six native apps are impressive and I absolutely agree I’d go for a performant API &amp; native app combination when powered by LLMs. But they are all also based on well-established, native, well-maintained, well-documented, platform frameworks like SwiftUI, WinUI, Jetpack Compose, Qt.</p>

<p>The web has one of those too. As <a href="https://youtu.be/QMkZ2J24XGI?t=866">Kinsey Durham Grace</a> put it: “If you designed a framework from scratch for agents, you’d land close to Rails.”</p>

<p>Without aim, a grenade just blows holes in whatever it lands on. Not sure waiting 5 minutes for the models is a good solution to having to solve problems now.</p>

<h2 id="rails-world-answered-its-own-keynote">Rails World answered its own keynote</h2>

<p><a href="https://github.com/basecamp/hotcell">Hot Cell</a> was the only feature that made the keynote<sup id="fnref:4"><a href="#fn:4" class="footnote" rel="footnote" role="doc-noteref">4</a></sup> but <a href="https://youtu.be/swXl8M84YmM">Mike Dalessio’s talk</a> was absolutely about how AI is affecting open source (and specifically a Rails CVE) and a framework-shaped answer to <em>holy shit the robots are coming to hack your systems</em>.</p>

<p><a href="https://youtu.be/L6-3a_rwLIg">Joël Quenneville</a> &amp; <a href="https://youtu.be/aornGet-EoI">Rachael Wright-Munn</a> talked about fixing the system and bolting deterministic hooks, or <a href="https://youtu.be/aornGet-EoI?t=536">context-injecting generators</a>, onto your agentic tools. Before Rachael’s custom generators carried RubyEvents’ conventions, the AI <a href="https://youtu.be/aornGet-EoI?t=116">“would invent keys”</a>. Merging <a href="https://youtu.be/REtfdqtW3PY">Marco Roth’s Herb</a> into Rails gives us a parsing tool for the view layer that can escalate errors into an agent’s context.</p>

<p><a href="https://youtu.be/QMkZ2J24XGI">Kinsey</a> also brought numbers from her time on GitHub’s Copilot coding agent team: 84% of developers use AI to write code, but only 29% trust it. <a href="https://youtu.be/QMkZ2J24XGI?t=164">“Generation is really, really cheap, and verification is not”</a>, so reviewing harder won’t fix it. Her answer: <a href="https://youtu.be/QMkZ2J24XGI?t=1238">“Constraints beat instruction”</a>. A rule in AGENTS.md fades as the context fills; a CI check that fails the build applies every time and costs no tokens. It’s also why Rachael’s generators work. With a generator, <a href="https://youtu.be/QMkZ2J24XGI?t=1573">“you’re making the command that puts [service objects] there cheaper than writing the file by hand. It’ll take the cheap path because agents always do.”</a></p>

<p>Robby argued for improving the “workshop” of our apps, making the preferred way obvious for humans and agents. Applying our curiosity to the problems we see in our day to day and leaving things better than we found them.</p>

<p>Jeremy <a href="https://youtu.be/IEOqgPfGLQ8?t=3131">asked what’s past Rails</a>: “I want to keep Rails… what about the values of Rails?” The answer was <a href="https://youtu.be/IEOqgPfGLQ8?t=3250">“find your tribe… stick around.”</a> That’s not a plan for Rails. David’s own tribe, and his energy, is clearly Omarchy, his agent-focussed Linux distribution.</p>

<p>People move on. That’s fine. There’s an entire army of people wanting to do, and already doing, the work before the models (maybe) do all software engineering and the framework doesn’t matter.</p>

<p>8.1.4, <a href="https://rubyonrails.org/2026/9/24/Rails-Version-8-1-4-has-been-released">a bugfix release</a>, came out on day 2 of the conference. There’s a bunch of cool stuff in 8.2. There’s no doubt an editing job to do to make Rails useful through the AI-chaos era that <a href="https://youtu.be/swXl8M84YmM?t=1527">Mike identified in his talk</a>.</p>

<h2 id="look-at-all-the-things-we-can-do">Look at all the things we can do</h2>

<p>There’s an alt-history version of this talk that pivots on the delight of David’s 21-year-old demo <a href="https://youtu.be/vDjW_dRyKXY?t=1144">“look at all the things I’m not doing”</a> into a vision for how Rails helps the models and the humans compress and accelerate. Then it was the framework, now it’s the framework and the model. David credited only the model.</p>

<p>Kinsey closed with the version I wanted from the keynote: <a href="https://youtu.be/QMkZ2J24XGI?t=1795">“Convention over configuration was never really about productivity. It was about care. A predictable and clear place to put things is a message to whoever shows up next… We called it a framework, because somebody always comes next.”</a></p>

<p>No doubt Rails needs to change—some things in, some things out—but it felt to me like a resignation from this version of his world view. If David’s focus has moved on, that’s his call. There was enough can-do, roll-your-sleeves-up attitude in the rest of the conference to take on the uncertain future.</p>

<p>Mike <a href="https://youtu.be/swXl8M84YmM?t=62">opened with a timeline</a>: prehistory to modern computing, then “you are here” in a band marked “CHAOS!”, and a giant question mark. That question mark <em>is</em> step 2. It won’t look like the old work. But it’s our work, and you don’t need permission to do it.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>He <a href="https://youtu.be/vDjW_dRyKXY?t=2038">praised it in the keynote</a> and called it <a href="https://youtu.be/IEOqgPfGLQ8?t=2736">‘a short-term consideration’</a> the next day. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;&#xFE0E;</a></p>
    </li>
    <li id="fn:2">
      <p>Stage 2 gives models 20 feature tickets, written the way a product person would, against 37signals’ Fizzy app, graded by hidden checks. At default settings the <a href="https://rubyonrails.org/2026/9/9/agents-on-rails-stage-2">best score was 35%</a>. The 53.3% is the same model, GPT-6 Astra, at <a href="https://rubyonrails.org/2026/9/21/agents-on-rails-maximum-effort-and-deepseek-4-1-flash">maximum effort</a>: 2.6 times the cost and almost three times the time. Claude Fable 5.1 spent twice as much at max for the same 32%. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;&#xFE0E;</a></p>
    </li>
    <li id="fn:3">
      <p>Simon Willison <a href="https://simonwillison.net/2025/Dec/15/porting-justhtml/">ported</a> a standards-compliant HTML parser (<a href="https://github.com/EmilStenstrom/justhtml">JustHTML</a>) from Python to JS in four and a half hours. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;&#xFE0E;</a></p>
    </li>
    <li id="fn:4">
      <p>In passing, at <a href="https://youtu.be/vDjW_dRyKXY?t=3585">59:45</a>. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;&#xFE0E;</a></p>
    </li>
  </ol>
</div>
]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[Use class_names to Conditionally Apply CSS Classes]]></title>
    <link href="https://andycroll.com/ruby/use-class-names-to-conditionally-apply-css-classes/"/>
    <updated>2026-05-11T09:00:00+00:00</updated>
    <id>https://andycroll.com/ruby/use-class-names-to-conditionally-apply-css-classes</id>
    <content type="html"><![CDATA[<p>When you’re building views in Rails, you often need to apply CSS classes conditionally. Maybe a nav link should look different when it’s the current page, or a form field needs error styling. Since Rails 6.1, the <a href="https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-class_names"><code class="language-plaintext highlighter-rouge">class_names</code></a> helper does this cleanly.</p>

<h2 id="instead-of">Instead of…</h2>

<p>…interpolating conditional classes with ternaries or post-statement conditionals:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"p-4 rounded </span><span class="cp">&lt;%=</span> <span class="vi">@error</span> <span class="p">?</span> <span class="s1">'bg-red-50 border-red-500'</span> <span class="p">:</span> <span class="s1">''</span> <span class="cp">%&gt;</span><span class="s"> </span><span class="cp">&lt;%=</span> <span class="s1">'opacity-50 cursor-not-allowed'</span> <span class="k">if</span> <span class="vi">@disabled</span> <span class="cp">%&gt;</span><span class="s">"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%=</span> <span class="vi">@message</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h2 id="use">Use…</h2>

<p>…the <code class="language-plaintext highlighter-rouge">class_names</code> helper:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">tag</span><span class="p">.</span><span class="nf">div</span> <span class="ss">class: </span><span class="n">class_names</span><span class="p">(</span>
  <span class="s2">"p-4 rounded"</span><span class="p">,</span>
  <span class="s2">"bg-red-50 border-red-500"</span><span class="p">:</span> <span class="vi">@error</span><span class="p">,</span>
  <span class="s2">"opacity-50 cursor-not-allowed"</span><span class="p">:</span> <span class="vi">@disabled</span>
<span class="p">)</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="vi">@message</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">String</code> arguments are always applied; trailing keyword-style entries are included when their value is truthy and silently dropped otherwise.</p>

<p>Better still — Rails tag helpers (<code class="language-plaintext highlighter-rouge">tag.*</code>, <code class="language-plaintext highlighter-rouge">link_to</code>, form builders) already run the <code class="language-plaintext highlighter-rouge">class:</code> argument through the process implicitly, so you can drop the wrapper and hand it an array directly. The trailing <code class="language-plaintext highlighter-rouge">key: value</code> pairs don’t need their own <code class="language-plaintext highlighter-rouge">{}</code> either; Ruby wraps them into a <code class="language-plaintext highlighter-rouge">Hash</code> for you:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">tag</span><span class="p">.</span><span class="nf">div</span> <span class="ss">class: </span><span class="p">[</span>
  <span class="s2">"p-4 rounded"</span><span class="p">,</span>
  <span class="s2">"bg-red-50 border-red-500"</span><span class="p">:</span> <span class="vi">@error</span><span class="p">,</span>
  <span class="s2">"opacity-50 cursor-not-allowed"</span><span class="p">:</span> <span class="vi">@disabled</span>
<span class="p">]</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="vi">@message</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>An active nav link:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Home"</span><span class="p">,</span> <span class="n">root_path</span><span class="p">,</span>
  <span class="ss">class: </span><span class="p">[</span><span class="s2">"nav-link px-3 py-2"</span><span class="p">,</span>
    <span class="s2">"text-blue-700 font-semibold"</span><span class="p">:</span> <span class="n">current_page?</span><span class="p">(</span><span class="n">root_path</span><span class="p">)]</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>A form field with errors:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:email</span><span class="p">,</span>
  <span class="ss">class: </span><span class="p">[</span><span class="s2">"field"</span><span class="p">,</span> <span class="s2">"field--error"</span><span class="p">:</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">errors</span><span class="p">[</span><span class="ss">:email</span><span class="p">].</span><span class="nf">any?</span><span class="p">]</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>A flash message:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%</span> <span class="n">flash</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">type</span><span class="p">,</span> <span class="n">message</span><span class="o">|</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">tag</span><span class="p">.</span><span class="nf">p</span> <span class="n">message</span><span class="p">,</span> <span class="ss">class: </span><span class="p">[</span><span class="s2">"flash"</span><span class="p">,</span>
    <span class="ss">notice: </span><span class="n">type</span> <span class="o">==</span> <span class="s2">"notice"</span><span class="p">,</span>
    <span class="ss">alert: </span><span class="n">type</span> <span class="o">==</span> <span class="s2">"alert"</span><span class="p">]</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>An active tab:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Overview"</span><span class="p">,</span> <span class="n">project_path</span><span class="p">(</span><span class="vi">@project</span><span class="p">),</span>
  <span class="ss">class: </span><span class="p">[</span><span class="s2">"tab"</span><span class="p">,</span> <span class="s2">"tab--active"</span><span class="p">:</span> <span class="n">current_page?</span><span class="p">(</span><span class="n">project_path</span><span class="p">(</span><span class="vi">@project</span><span class="p">))]</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>Or wrap a repeated pattern in a helper. Helpers often return <code class="language-plaintext highlighter-rouge">String</code>s, which would be a case where you call <code class="language-plaintext highlighter-rouge">class_names</code> directly:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">class_names_for_project</span><span class="p">(</span><span class="n">project</span><span class="p">)</span>
  <span class="n">class_names</span><span class="p">(</span><span class="s2">"status-badge"</span><span class="p">,</span>
    <span class="s2">"status-badge--primary"</span><span class="p">:</span> <span class="n">project</span><span class="p">.</span><span class="nf">active?</span><span class="p">,</span>
    <span class="s2">"status-badge--muted"</span><span class="p">:</span> <span class="n">project</span><span class="p">.</span><span class="nf">archived?</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">tag</span><span class="p">.</span><span class="nf">span</span> <span class="vi">@project</span><span class="p">.</span><span class="nf">status</span><span class="p">,</span> <span class="ss">class: </span><span class="n">class_names_for_project</span><span class="p">(</span><span class="vi">@project</span><span class="p">)</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<h2 id="why">Why?</h2>

<p>The unsophisticated approach ends up with extra whitespace in the rendered HTML with ERB tags inside an HTML attribute (which I’m not a fan of visually), plus it’s hard to scan which classes are always present and which are conditional.</p>

<p>The tag helpers call <code class="language-plaintext highlighter-rouge">token_list</code> on whatever you pass to <code class="language-plaintext highlighter-rouge">class:</code>, which is aliased as <code class="language-plaintext highlighter-rouge">class_names</code>. It splits whitespace-separated tokens, deduplicates them, and returns an HTML-safe string. So <code class="language-plaintext highlighter-rouge">["p-4", "p-4 rounded"]</code> collapses to <code class="language-plaintext highlighter-rouge">"p-4 rounded"</code> rather than repeating <code class="language-plaintext highlighter-rouge">p-4</code>.</p>

<p>You have to call <code class="language-plaintext highlighter-rouge">class_names</code> directly when you’re not inside a tag helper — building a string in a helper method, or interpolating into raw HTML. It’s available in all views and helpers in Rails since it’s defined in <code class="language-plaintext highlighter-rouge">ActionView::Helpers::TagHelper</code>. You might also see it referred to as <code class="language-plaintext highlighter-rouge">token_list</code>, which is the original method name.</p>

<h2 id="why-not">Why not?</h2>

<p>If you’ve only got a single conditional class, plain ERB is readable enough:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"p-4 </span><span class="cp">&lt;%=</span> <span class="s1">'font-bold'</span> <span class="k">if</span> <span class="vi">@important</span> <span class="cp">%&gt;</span><span class="s">"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Though the array form does avoid the awkward whitespace issue when the condition is false:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">tag</span><span class="p">.</span><span class="nf">div</span> <span class="ss">class: </span><span class="p">[</span><span class="s2">"p-4"</span><span class="p">,</span> <span class="s2">"font-bold"</span><span class="p">:</span> <span class="vi">@important</span><span class="p">]</span> <span class="k">do</span> <span class="cp">%&gt;</span>
</code></pre></div></div>
]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[Avoid html_safe with Tag Helpers, safe_join, and sanitize]]></title>
    <link href="https://andycroll.com/ruby/alternatives-to-html-safe-in-rails/"/>
    <updated>2026-05-04T09:00:00+00:00</updated>
    <id>https://andycroll.com/ruby/alternatives-to-html-safe-in-rails</id>
    <content type="html"><![CDATA[<p>When you need to build HTML outside of a template, it’s tempting to concatenate strings and call <code class="language-plaintext highlighter-rouge">html_safe</code> on the result. This bypasses Rails’s built-in <a href="/ruby/beware-of-raw-erb/">XSS protection</a> entirely: any user input in that string goes straight to the browser unescaped.</p>

<p>The good news is you almost never need <code class="language-plaintext highlighter-rouge">html_safe</code>. Rails provides three underappreciated tools that handle escaping for you.</p>

<h2 id="instead-of">Instead of…</h2>

<p>…calling <code class="language-plaintext highlighter-rouge">html_safe</code> on strings you’ve built by hand:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">status_badge</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="n">color</span><span class="p">)</span>
  <span class="s2">"&lt;span class=</span><span class="se">\"</span><span class="s2">badge badge-</span><span class="si">#{</span><span class="n">color</span><span class="si">}</span><span class="se">\"</span><span class="s2">&gt;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2">&lt;/span&gt;"</span><span class="p">.</span><span class="nf">html_safe</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">formatted_address</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
  <span class="p">[</span><span class="n">user</span><span class="p">.</span><span class="nf">street</span><span class="p">,</span> <span class="n">user</span><span class="p">.</span><span class="nf">city</span><span class="p">,</span> <span class="n">user</span><span class="p">.</span><span class="nf">postcode</span><span class="p">].</span><span class="nf">compact</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"&lt;br&gt;"</span><span class="p">).</span><span class="nf">html_safe</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">render_comment</span><span class="p">(</span><span class="n">comment</span><span class="p">)</span>
  <span class="n">comment</span><span class="p">.</span><span class="nf">body_html</span><span class="p">.</span><span class="nf">html_safe</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="use">Use…</h2>

<p>…the right tool for each situation.</p>

<p>When you need to <strong>build HTML elements</strong>, use <code class="language-plaintext highlighter-rouge">tag</code> helpers:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">status_badge</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="n">color</span><span class="p">)</span>
  <span class="n">tag</span><span class="p">.</span><span class="nf">span</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"badge badge-</span><span class="si">#{</span><span class="n">color</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The <a href="https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html"><code class="language-plaintext highlighter-rouge">tag</code> helper</a> escapes the content and attributes automatically. It returns an HTML-safe string without you having to think about it.</p>

<p>When you need to <strong>join fragments</strong> that mix safe HTML with potentially unsafe text, use <code class="language-plaintext highlighter-rouge">safe_join</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">formatted_address</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
  <span class="n">safe_join</span><span class="p">([</span><span class="n">user</span><span class="p">.</span><span class="nf">street</span><span class="p">,</span> <span class="n">user</span><span class="p">.</span><span class="nf">city</span><span class="p">,</span> <span class="n">user</span><span class="p">.</span><span class="nf">postcode</span><span class="p">].</span><span class="nf">compact</span><span class="p">,</span> <span class="n">tag</span><span class="p">.</span><span class="nf">br</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p><a href="https://api.rubyonrails.org/classes/ActionView/Helpers/OutputSafetyHelper.html#method-i-safe_join"><code class="language-plaintext highlighter-rouge">safe_join</code></a> escapes any unsafe strings in the array and returns an HTML-safe result. It’s <code class="language-plaintext highlighter-rouge">Array#join</code> with protection built in.</p>

<p>When you need to <strong>accept user-provided HTML</strong> but strip dangerous tags, use <code class="language-plaintext highlighter-rouge">sanitize</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">render_comment</span><span class="p">(</span><span class="n">comment</span><span class="p">)</span>
  <span class="n">sanitize</span><span class="p">(</span><span class="n">comment</span><span class="p">.</span><span class="nf">body_html</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p><a href="https://api.rubyonrails.org/classes/ActionView/Helpers/SanitizeHelper.html#method-i-sanitize"><code class="language-plaintext highlighter-rouge">sanitize</code></a> keeps safe tags like <code class="language-plaintext highlighter-rouge">&lt;p&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;strong&gt;</code>, and <code class="language-plaintext highlighter-rouge">&lt;em&gt;</code> while stripping <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code>, event handlers, and other XSS vectors. You can customise the allowed tags and attributes, but beware of straying past the defaults — they are battle-tested and loosening them is a <a href="https://en.wiktionary.org/wiki/footgun">footgun</a>.</p>

<h2 id="why">Why?</h2>

<p>Each of these tools lets Rails manage HTML safety for you. You describe what you want — an element, a joined list, sanitised content — and the framework handles the escaping.</p>

<p><code class="language-plaintext highlighter-rouge">html_safe</code> does the opposite. It tells Rails “trust this string, don’t escape it”. That’s a promise <em>you</em> have to keep, and it’s easy to break when the inputs change or a future developer doesn’t realise user data flows through that path. Ask your friendly security consultant or penetration testing organisation why this is a bad idea.</p>

<p>The mental model is simple. Need an HTML element? <code class="language-plaintext highlighter-rouge">tag</code>. Joining fragments? <code class="language-plaintext highlighter-rouge">safe_join</code>. Accepting rich text? <code class="language-plaintext highlighter-rouge">sanitize</code>. If none of those fit, you probably need a partial or a component, not a string.</p>

<h2 id="why-not">Why not?</h2>

<p>There are legitimate uses of <code class="language-plaintext highlighter-rouge">html_safe</code>. Some gems, like <a href="https://github.com/ddnexus/pagy"><code class="language-plaintext highlighter-rouge">pagy</code></a>, return pre-built HTML strings that are safe by construction. Calling <code class="language-plaintext highlighter-rouge">html_safe</code> on their output is fine because the gem controls the content.</p>

<p>You might also see <code class="language-plaintext highlighter-rouge">html_safe</code> on strings that are genuinely static with no user input, like <code class="language-plaintext highlighter-rouge">"&amp;nbsp;".html_safe</code>. That’s harmless, but you can include the actual Unicode character instead — <code class="language-plaintext highlighter-rouge">"\u00A0"</code> gives you a non-breaking space without needing <code class="language-plaintext highlighter-rouge">html_safe</code> at all. That is ugly as hell though, so it’s your call!</p>

<p>The key question is always: could user input end up in this string? If the answer is yes, or even <em>maybe</em>, reach for <code class="language-plaintext highlighter-rouge">tag</code>, <code class="language-plaintext highlighter-rouge">safe_join</code>, or <code class="language-plaintext highlighter-rouge">sanitize</code> instead.</p>
]]></content>
  </entry>
  
</feed>
