Add sole dataset extension and use it for the asset route
Adds a `sole` Sequel dataset method (mirroring the ketchup plugin) that
returns the single matching row, raising Sequel::NoMatchingRow on none
and Sequel::Sole::TooManyRows on more than one. Shipped as a dataset
extension applied via extend_datasets so it works on Domus's raw
datasets without introducing a Sequel::Model layer.

The asset route now reads db[:assets].where(id:).sole and lets the
error_handler map NoMatchingRow to a 404, instead of fetching + manually
checking nil and raising ClientError.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N92HLTepkg3AtRpDUyZ54o
change
commit a99f799b44eb4cfc94c701ec7cd6fd63161e04c7
author Claude <noreply@anthropic.com>
date
parent 61e3e8c7
diff --git a/lib/app.rb b/lib/app.rb
index 0f6be97..a8f53fc 100644
--- a/lib/app.rb
+++ b/lib/app.rb
@@ -2,6 +2,7 @@
 
 require "sequel"
 require_relative "config"
+require_relative "sequel/extensions/sole"
 
 module Domus
   class App
@@ -11,6 +12,7 @@ module Domus
     def initialize(config = Config.env)
       @config = config
       @db = Sequel.sqlite(config.database_url)
+      @db.extend_datasets(Sequel::Sole::DatasetMethods)
     end
 
     # : (Hash[Symbol, untyped]) -> Pathname
diff --git a/lib/sequel/extensions/sole.rb b/lib/sequel/extensions/sole.rb
new file mode 100644
index 0000000..e9cd62d
--- /dev/null
+++ b/lib/sequel/extensions/sole.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require "sequel"
+
+module Sequel
+  # The sole extension adds a +sole+ dataset method that returns the single
+  # matching row, raising if zero or more than one row matches. It mirrors the
+  # ketchup plugin of the same name, but as a dataset extension so it works on
+  # Domus's raw datasets without a Sequel::Model layer.
+  #
+  #   db[:assets].where(id: 1).sole  # => {id: 1, ...}
+  #   db[:assets].where(id: 0).sole  # raises Sequel::NoMatchingRow
+  #   db[:assets].sole               # raises Sequel::Sole::TooManyRows (if > 1)
+  #
+  # Apply it to every dataset of a database with:
+  #
+  #   db.extend_datasets(Sequel::Sole::DatasetMethods)
+  module Sole
+    class TooManyRows < Sequel::Error; end
+
+    module DatasetMethods
+      def sole
+        results = limit(2).all
+        raise Sequel::NoMatchingRow.new(self) if results.empty?
+        raise TooManyRows, "expected 1 row, got multiple" if results.length > 1
+
+        results.first
+      end
+    end
+  end
+
+  Dataset.register_extension(:sole, Sole::DatasetMethods)
+end
diff --git a/lib/web.rb b/lib/web.rb
index ad4a177..bdc0c0d 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -23,10 +23,16 @@ module Domus
     plugin :public
     plugin :all_verbs
     plugin :error_handler do |e|
-      raise e unless e.is_a?(ClientError)
-
-      response.status = e.status
-      e.message
+      case e
+      when ClientError
+        response.status = e.status
+        e.message
+      when Sequel::NoMatchingRow
+        response.status = 404
+        "Not found."
+      else
+        raise e
+      end
     end
 
     IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif].freeze
@@ -58,10 +64,9 @@ module Domus
       r.on "assets" do
         r.is Integer do |id|
           r.get do
-            asset = db[:assets].first(id:)
-            raise ClientError.new("Asset not found.", status: 404) unless asset
-
-            Views::Asset.new(asset:).call
+            # .sole raises Sequel::NoMatchingRow when the id is unknown; the
+            # error_handler above turns that into a 404.
+            Views::Asset.new(asset: db[:assets].where(id:).sole).call
           end
         end
       end
diff --git a/test/test_sole.rb b/test/test_sole.rb
new file mode 100644
index 0000000..e63aae5
--- /dev/null
+++ b/test/test_sole.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+
+class TestSole < Minitest::Test
+  def db = Domus::Web.opts.fetch(:app).db
+
+  def setup
+    db[:asset_attachments].delete
+    db[:assets].delete
+  end
+
+  def test_sole_returns_the_single_matching_row
+    id = db[:assets].insert(name: "Only", created_at: Time.now)
+    assert_equal "Only", db[:assets].where(id:).sole[:name]
+  end
+
+  def test_sole_raises_when_no_rows_match
+    assert_raises(Sequel::NoMatchingRow) do
+      db[:assets].where(id: 999_999).sole
+    end
+  end
+
+  def test_sole_raises_when_multiple_rows_match
+    db[:assets].insert(name: "A", created_at: Time.now)
+    db[:assets].insert(name: "B", created_at: Time.now)
+
+    assert_raises(Sequel::Sole::TooManyRows) { db[:assets].sole }
+  end
+end