From e2ccc482462d43c7dd801ca016a32d1f884288f5 Mon Sep 17 00:00:00 2001 From: Asherah Connor Date: Tue, 24 Nov 2020 10:32:11 +1100 Subject: [PATCH] Add `ActiveRecord::FinderMethods#sole`, `#find_sole_by` Co-authored-by: Kasper Timm Hansen --- activerecord/CHANGELOG.md | 19 +++++++++++++ activerecord/lib/active_record/errors.rb | 10 +++++++ activerecord/lib/active_record/querying.rb | 2 +- .../active_record/relation/finder_methods.rb | 27 +++++++++++++++++++ activerecord/test/cases/finder_test.rb | 23 ++++++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index c300599058..70d9370e65 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -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) + # => # (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. diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index ffb6978daf..5c82264a95 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -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+. diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 17e452640b..c7866378b2 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -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?, diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 9513e035af..3eea5fa157 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -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. # diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index bfc44b718c..fe9605db39 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -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