Add `ActiveRecord::FinderMethods#sole`, `#find_sole_by`

Co-authored-by: Kasper Timm Hansen <kaspth@gmail.com>
This commit is contained in:
Asherah Connor 2020-11-24 10:32:11 +11:00
parent 401ce9dd3d
commit e2ccc48246
5 changed files with 80 additions and 1 deletions

View File

@ -1,3 +1,22 @@
* Add `FinderMethods#sole` and `#find_sole_by` to find and assert the
presence of exactly one record.
Used when you need a single row, but also want to assert that there aren't
multiple rows matching the condition; especially for when database
constraints aren't enough or are impractical.
```ruby
Product.where(["price = %?", price]).sole
# => ActiveRecord::RecordNotFound (if no Product with given price)
# => #<Product ...> (if one Product with given price)
# => ActiveRecord::SoleRecordExceeded (if more than one Product with given price)
user.api_keys.find_by_sole(key: key)
# as above
```
*Asherah Connor*
* Makes `ActiveRecord::AttributeMethods::Query` respect the getter overrides defined in the model.
Fixes #40771.

View File

@ -118,6 +118,16 @@ module ActiveRecord
end
end
# Raised when Active Record finds multiple records but only expected one.
class SoleRecordExceeded < ActiveRecordError
attr_reader :record
def initialize(record = nil)
@record = record
super "Wanted only one #{record&.name || "record"}"
end
end
# Superclass for all database execution errors.
#
# Wraps the underlying database error as +cause+.

View File

@ -3,7 +3,7 @@
module ActiveRecord
module Querying
QUERYING_METHODS = [
:find, :find_by, :find_by!, :take, :take!, :first, :first!, :last, :last!,
:find, :find_by, :find_by!, :take, :take!, :sole, :find_sole_by, :first, :first!, :last, :last!,
:second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!,
:forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!,
:exists?, :any?, :many?, :none?, :one?,

View File

@ -104,6 +104,33 @@ module ActiveRecord
take || raise_record_not_found_exception!
end
# Finds the sole matching record. Raises ActiveRecord::RecordNotFound if no
# record is found. Raises ActiveRecord::SoleRecordExceeded if more than one
# record is found.
#
# Product.where(["price = %?", price]).sole
def sole
found, undesired = first(2)
case
when found.nil?
raise_record_not_found_exception!
when undesired.present?
raise ActiveRecord::SoleRecordExceeded.new(self)
else
found
end
end
# Finds the sole matching record. Raises ActiveRecord::RecordNotFound if no
# record is found. Raises ActiveRecord::SoleRecordExceeded if more than one
# record is found.
#
# Product.find_sole_by(["price = %?", price])
def find_sole_by(arg, *args)
where(arg, *args).sole
end
# Find the first record (or first N records if a parameter is supplied).
# If no order is defined it will order by primary key.
#

View File

@ -625,6 +625,29 @@ class FinderTest < ActiveRecord::TestCase
end
end
def test_sole
assert_equal topics(:first), Topic.where("title = 'The First Topic'").sole
assert_equal topics(:first), Topic.find_sole_by("title = 'The First Topic'")
end
def test_sole_failing_none
assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
Topic.where("title = 'This title does not exist'").sole
end
assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
Topic.find_sole_by("title = 'This title does not exist'")
end
end
def test_sole_failing_many
assert_raises_with_message ActiveRecord::SoleRecordExceeded, "Wanted only one Topic" do
Topic.where("author_name = 'Carl'").sole
end
assert_raises_with_message ActiveRecord::SoleRecordExceeded, "Wanted only one Topic" do
Topic.find_sole_by("author_name = 'Carl'")
end
end
def test_first
assert_equal topics(:second).title, Topic.where("title = 'The Second Topic of the day'").first.title
end