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
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