From 70d2cc805962e1c9f1ca5f6d29a53f4d7d0b7ccd Mon Sep 17 00:00:00 2001 From: Stefanni Brasil Date: Mon, 17 Nov 2025 16:40:24 -0700 Subject: [PATCH 1/6] Introduce support for deprecated associations API Rails 8.1 introduced the ability to mark Active Record associations as deprecated. This allows developers to mark associations for deprecation while maintaining backward compatibility. Assert association is deprecated: ```ruby it { should have_many(:posts).deprecated } it { should have_one(:profile).deprecated } it { should belong_to(:author).deprecated } ``` Assert association is NOT deprecated: ```ruby it { should have_many(:posts).deprecated(false) } ``` --- .../active_record/association_matcher.rb | 120 +++++++- .../active_record/association_matcher_spec.rb | 257 ++++++++++++++++++ 2 files changed, 375 insertions(+), 2 deletions(-) diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 1ce34cdfb..6a27f6709 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -374,6 +374,25 @@ module ActiveRecord # # @return [AssociationMatcher] # + # ##### deprecated + # + # Use `deprecated` to assert that the `:deprecated` option was specified. + # (Enabled by default in Rails 8.1+). + # + # class Account < ActiveRecord::Base + # belongs_to :bank, deprecated: true + # end + # + # # RSpec + # RSpec.describe Account, type: :model do + # it { should belong_to(:bank).deprecated(true) } + # end + # + # # Minitest (Shoulda) + # class AccountTest < ActiveSupport::TestCase + # should belong_to(:bank).deprecated(true) + # end + # def belong_to(name) AssociationMatcher.new(:belongs_to, name) end @@ -683,7 +702,25 @@ def belong_to(name) # # @return [AssociationMatcher] # - + # ##### deprecated + # + # Use `deprecated` to assert that the association is not allowed to be nil. + # (Enabled by default in Rails 8.1+). + # + # class Vehicle < ActiveRecord::Base + # delegated_type :drivable, types: %w(Car Truck), deprecated: true + # end + # + # # RSpec + # describe Vehicle + # it { should have_delegated_type(:drivable).deprecated } + # end + # + # # Minitest (Shoulda) + # class VehicleTest < ActiveSupport::TestCase + # should have_delegated_type(:drivable).deprecated + # end + # def have_delegated_type(name) AssociationMatcher.new(:belongs_to, name) end @@ -972,6 +1009,25 @@ def have_delegated_type(name) # # @return [AssociationMatcher] # + # ##### deprecated + # + # Use `deprecated` to assert that the `:deprecated` option was specified. + # (Enabled by default in Rails 8.1+) + # + # class Player < ActiveRecord::Base + # has_many :games, deprecated: true + # end + # + # # RSpec + # RSpec.describe Player, type: :model do + # it { should have_many(:games).deprecated(true) } + # end + # + # # Minitest (Shoulda) + # class PlayerTest < ActiveSupport::TestCase + # should have_many(:games).deprecated(true) + # end + # def have_many(name) AssociationMatcher.new(:has_many, name) end @@ -1219,6 +1275,25 @@ def have_many(name) # # @return [AssociationMatcher] # + # ##### deprecated + # + # Use `deprecated` to assert that the `:deprecated` option was specified. + # (Enabled by default in Rails 8.1+). + # + # class Account < ActiveRecord::Base + # has_one :bank, deprecated: true + # end + # + # # RSpec + # RSpec.describe Account, type: :model do + # it { should have_one(:bank).deprecated(true) } + # end + # + # # Minitest (Shoulda) + # class AccountTest < ActiveSupport::TestCase + # should have_one(:bank).deprecated(true) + # end + # def have_one(name) AssociationMatcher.new(:has_one, name) end @@ -1377,6 +1452,27 @@ def have_one(name) # # @return [AssociationMatcher] # + # ##### deprecated + # + # Use `deprecated` to assert that the `:deprecated` option was specified. + # (Enabled by default in Rails 8.1+). + # + # class Publisher < ActiveRecord::Base + # has_and_belongs_to_many :advertisers, deprecated: true + # end + # + # # RSpec + # RSpec.describe Publisher, type: :model do + # it { should have_and_belong_to_many(:advertisers).deprecated(true) } + # end + # + # # Minitest (Shoulda) + # class AccountTest < ActiveSupport::TestCase + # should have_and_belong_to_many(:advertisers).deprecated(true) + # end + # + # @return [AssociationMatcher] + # def have_and_belong_to_many(name) AssociationMatcher.new(:has_and_belongs_to_many, name) end @@ -1546,6 +1642,16 @@ def join_table(join_table_name) self end + def deprecated(deprecated = true) + if ::ActiveRecord::VERSION::STRING >= '8.1' + @options[:deprecated] = deprecated + self + else + raise NotImplementedError, + '`deprecated` association matcher is only available on Active Record >= 8.1.' + end + end + def without_validating_presence remove_submatcher(AssociationMatchers::RequiredMatcher) self @@ -1586,7 +1692,8 @@ def matches?(subject) touch_correct? && types_correct? && strict_loading_correct? && - submatchers_match? + submatchers_match? && + deprecated_correct? end def join_table_name @@ -1848,6 +1955,15 @@ def touch_correct? end end + def deprecated_correct? + if option_verifier.correct_for_boolean?(:deprecated, options[:deprecated]) + true + else + @missing = "#{name} should have deprecated: #{options[:deprecated]}" + false + end + end + def types_correct? if options.key?(:types) types = options[:types] diff --git a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb index b75677704..a3b42c1f3 100644 --- a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb @@ -749,6 +749,52 @@ def ensure_parent_is_set end end + if rails_version >= 8.1 + context 'an association with a :touch option' do + [false, true].each do |deprecated_value| + context "when the model has deprecated: #{deprecated_value}" do + it 'accepts a matching deprecated option' do + expect(belonging_to_parent(deprecated: deprecated_value)). + to belong_to(:parent).deprecated(deprecated_value) + end + + it 'rejects a non-matching deprecated option' do + expect(belonging_to_parent(deprecated: deprecated_value)). + not_to belong_to(:parent).deprecated(!deprecated_value) + end + + it 'defaults to deprecated(true)' do + if deprecated_value + expect(belonging_to_parent(deprecated: deprecated_value)). + to belong_to(:parent).deprecated + else + expect(belonging_to_parent(deprecated: deprecated_value)). + not_to belong_to(:parent).deprecated + end + end + + it 'will not break matcher when deprecated option is unspecified' do + expect(belonging_to_parent(deprecated: deprecated_value)).to belong_to(:parent) + end + end + end + end + + context 'an association without a :deprecated option' do + it 'accepts deprecated(false)' do + expect(belonging_to_parent).to belong_to(:parent).deprecated(false) + end + + it 'rejects deprecated(true)' do + expect(belonging_to_parent).not_to belong_to(:parent).deprecated(true) + end + + it 'rejects deprecated()' do + expect(belonging_to_parent).not_to belong_to(:parent).deprecated + end + end + end + def belonging_to_parent(options = {}, parent_options = {}, &block) child_model = create_child_model_belonging_to_parent( options, @@ -801,6 +847,18 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {}) end context 'have_many' do + if rails_version <= 8.1 + context 'using the deprecated matcher' do + it 'raises a NotImplementedError' do + expected_message = '`deprecated` association matcher is only available on Active Record >= 8.1.' + + expect do + expect(having_many_children(deprecated: false)).to have_many(:children).deprecated(false) + end.to raise_error(NotImplementedError, expected_message) + end + end + end + it 'accepts a valid association without any options' do expect(having_many_children).to have_many(:children) end @@ -1252,6 +1310,74 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {}) expect(Hotel.new).to have_many(:visitors).with_foreign_type(:facility_type) end + if rails_version >= 8.1 + context 'deprecated' do + it 'accepts deprecated(false) when the :deprecated option is false' do + expect(having_many_children(deprecated: false)).to have_many(:children).deprecated(false) + end + + it 'accepts deprecated(true) when the :deprecated option is true' do + expect(having_many_children(deprecated: true)).to have_many(:children).deprecated(true) + end + + it 'rejects deprecated(false) when the :deprecated option is true' do + expect(having_many_children(deprecated: true)).not_to have_many(:children).deprecated(false) + end + + it 'rejects deprecated(true) when the :deprecated option is false' do + expect(having_many_children(deprecated: false)).not_to have_many(:children).deprecated(true) + end + + it 'assumes deprecated() means deprecated(true)' do + expect(having_many_children(deprecated: true)).to have_many(:children).deprecated + end + + it 'rejects deprecated() when :deprecated option is false' do + expect(having_many_children(deprecated: false)).not_to have_many(:children).deprecated + end + + it 'rejects deprecated(true) when no :deprecated option was specified' do + expect(having_many_children).not_to have_many(:children).deprecated(true) + end + + it 'rejects deprecated(false) when no :deprecated option was specified' do + expect(having_many_children).to have_many(:children).deprecated(false) + end + + it 'rejects deprecated() when no :deprecated option was specified' do + expect(having_many_children).not_to have_many(:children).deprecated + end + + it 'accepts an association :through with a :deprecated option' do + define_model(:author) do + has_many :books + has_many :paperbacks, through: :books, source: :format, source_type: 'Paperback', deprecated: true + end + define_model(:book, format_id: :integer) do + belongs_to :format, polymorphic: true + end + define_model(:paperback) + + expect(Author.new).to have_many(:paperbacks).source(:format).deprecated + expect(Author.new).not_to have_many(:paperbacks).source(:format).deprecated(false) + end + + it 'accepts an association :through without a :deprecated option' do + define_model(:author) do + has_many :books + has_many :paperbacks, through: :books, source: :format, source_type: 'Paperback' + end + define_model(:book, format_id: :integer) do + belongs_to :format, polymorphic: true + end + define_model(:paperback) + + expect(Author.new).to have_many(:paperbacks).source(:format).deprecated(false) + expect(Author.new).not_to have_many(:paperbacks).source(:format).deprecated(true) + end + end + end + describe 'strict_loading' do context 'when the application is configured with strict_loading disabled by default' do it 'accepts an association with a matching :strict_loading option' do @@ -1922,6 +2048,67 @@ def having_many_non_existent_class(model_name, assoc_name, options = {}) }.to fail_with_message(message) end + if rails_version >= 8.1 + it 'accepts an association with a matching :deprecated option' do + define_model :detail, person_id: :integer, disabled: :boolean + define_model :person do + has_one :detail, deprecated: true + end + + expect(Person.new).to have_one(:detail).deprecated(true) + end + + it 'rejects an association with a non-matching :deprecated option' do + define_model :detail, person_id: :integer, disabled: :boolean + define_model :person do + has_one :detail, deprecated: false + end + + expect(Person.new).to have_one(:detail).deprecated(false) + end + + it 'rejects an association with a non-matching :deprecated option when no option is passed' do + define_model :detail, person_id: :integer, disabled: :boolean + define_model :person do + has_one :detail + end + + expect(Person.new).to have_one(:detail).deprecated(false) + end + + it 'accepts an association :through with a :deprecated option' do + define_model :detail + + define_model :account do + has_one :detail + end + + define_model :person do + has_one :account + has_one :detail, through: :account, deprecated: true + end + + expect(Person.new).to have_one(:detail).through(:account).deprecated + expect(Person.new).not_to have_one(:detail).through(:account).deprecated(false) + end + + it 'accepts an association :through without a :deprecated option' do + define_model :detail + + define_model :account do + has_one :detail + end + + define_model :person do + has_one :account + has_one :detail, through: :account + end + + expect(Person.new).to have_one(:detail).through(:account).deprecated(false) + expect(Person.new).not_to have_one(:detail).through(:account).deprecated(true) + end + end + it 'accepts an association with a through' do define_model :detail @@ -2613,6 +2800,30 @@ def having_one_non_existent(model_name, assoc_name, options = {}) end end + if rails_version >= 8.1 + context 'deprecated' do + it 'accepts when the :deprecated option matches' do + expect(having_and_belonging_to_many_relatives(deprecated: false)). + to have_and_belong_to_many(:relatives).deprecated(false) + end + + it 'rejects when the :deprecated option does not match' do + expect(having_and_belonging_to_many_relatives(deprecated: true)). + to have_and_belong_to_many(:relatives).deprecated(false) + end + + it 'assumes deprecated() means deprecated(true)' do + expect(having_and_belonging_to_many_relatives(deprecated: false)). + not_to have_and_belong_to_many(:relatives).deprecated + end + + it 'matches deprecated(false) to having no deprecated option specified' do + expect(having_and_belonging_to_many_relatives). + to have_and_belong_to_many(:relatives).deprecated(false) + end + end + end + def having_and_belonging_to_many_relatives(_options = {}) define_model :relative define_model :people_relative, id: false, person_id: :integer, @@ -2848,6 +3059,52 @@ def having_and_belonging_to_many_non_existent_class(model_name, assoc_name, opti end end + if rails_version >= 8.1 + context 'an association with a :deprecated option' do + [false, true].each do |deprecated_value| + context "when the model has deprecated: #{deprecated_value}" do + it 'accepts a matching deprecated option' do + expect(delegating_type_to_drivable(deprecated: deprecated_value)). + to have_delegated_type(:drivable).deprecated(deprecated_value) + end + + it 'rejects a non-matching deprecated option' do + expect(delegating_type_to_drivable(deprecated: deprecated_value)). + not_to have_delegated_type(:drivable).deprecated(!deprecated_value) + end + + it 'defaults to deprecated(true)' do + if deprecated_value + expect(delegating_type_to_drivable(deprecated: deprecated_value)). + to have_delegated_type(:drivable).deprecated + else + expect(delegating_type_to_drivable(deprecated: deprecated_value)). + not_to have_delegated_type(:drivable).deprecated + end + end + + it 'will not break matcher when deprecated option is unspecified' do + expect(delegating_type_to_drivable(deprecated: deprecated_value)).to have_delegated_type(:drivable) + end + end + end + end + + context 'an association without a :deprecated option' do + it 'accepts deprecated(false)' do + expect(delegating_type_to_drivable).to have_delegated_type(:drivable).deprecated(false) + end + + it 'rejects deprecated(true)' do + expect(delegating_type_to_drivable).not_to have_delegated_type(:drivable).deprecated(true) + end + + it 'rejects deprecated()' do + expect(delegating_type_to_drivable).not_to have_delegated_type(:drivable).deprecated + end + end + end + context 'given the association is neither configured to be required nor optional' do context 'when qualified with required(true)' do context 'when belongs_to is configured to be required by default' do From 9450ae8ecf2eb5b51227530ebde08626c764131c Mon Sep 17 00:00:00 2001 From: Stefanni Brasil Date: Wed, 19 Nov 2025 12:41:46 -0700 Subject: [PATCH 2/6] Add a active_record_gte_8_1? helper Let's use the Shoulda::Matchers::RailsShim class to encapsulate this logic. I also updated the unit tests to have an active_record_version helper. --- .../active_record/association_matcher.rb | 2 +- lib/shoulda/matchers/rails_shim.rb | 8 ++++++++ spec/support/unit/helpers/rails_versions.rb | 8 ++++++++ .../active_record/association_matcher_spec.rb | 18 +++++++++++++++--- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 6a27f6709..2e847f96d 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -1643,7 +1643,7 @@ def join_table(join_table_name) end def deprecated(deprecated = true) - if ::ActiveRecord::VERSION::STRING >= '8.1' + if Shoulda::Matchers::RailsShim.active_record_gte_8_1? @options[:deprecated] = deprecated self else diff --git a/lib/shoulda/matchers/rails_shim.rb b/lib/shoulda/matchers/rails_shim.rb index 09bd41d11..0fd4daf61 100644 --- a/lib/shoulda/matchers/rails_shim.rb +++ b/lib/shoulda/matchers/rails_shim.rb @@ -25,6 +25,10 @@ def active_model_lt_7? Gem::Requirement.new('< 7').satisfied_by?(active_model_version) end + def active_record_gte_8_1? + Gem::Requirement.new('>= 8.1').satisfied_by?(active_record_version) + end + def generate_validation_message( record, attribute, @@ -143,6 +147,10 @@ def validates_column_options? Gem::Requirement.new('>= 7.1.0').satisfied_by?(active_record_version) end + def active_record_lt_8_1? + Gem::Requirement.new('< 7').satisfied_by?(active_model_version) + end + private def simply_generate_validation_message( diff --git a/spec/support/unit/helpers/rails_versions.rb b/spec/support/unit/helpers/rails_versions.rb index 4324f6fc6..503606692 100644 --- a/spec/support/unit/helpers/rails_versions.rb +++ b/spec/support/unit/helpers/rails_versions.rb @@ -11,6 +11,10 @@ def rails_version Tests::Version.new(Rails::VERSION::STRING) end + def active_record_version + Tests::Version.new(::ActiveModel::VERSION::STRING) + end + def rails_oldest_version_supported 7.1 end @@ -18,5 +22,9 @@ def rails_oldest_version_supported def rails_gt_8_0? rails_version >= '8.0' end + + def active_record_gte_8_1? + active_record_version >= '8.1' + end end end diff --git a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb index a3b42c1f3..9d2da58a8 100644 --- a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb @@ -749,7 +749,19 @@ def ensure_parent_is_set end end - if rails_version >= 8.1 + unless active_record_gte_8_1? + context 'using the deprecated matcher' do + it 'raises a NotImplementedError' do + expected_message = '`deprecated` association matcher is only available on Active Record >= 8.1.' + + expect do + expect(belonging_to_parent).to belong_to(:parent).deprecated + end.to raise_error(NotImplementedError, expected_message) + end + end + end + + if active_record_gte_8_1? context 'an association with a :touch option' do [false, true].each do |deprecated_value| context "when the model has deprecated: #{deprecated_value}" do @@ -847,13 +859,13 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {}) end context 'have_many' do - if rails_version <= 8.1 + unless active_record_gte_8_1? context 'using the deprecated matcher' do it 'raises a NotImplementedError' do expected_message = '`deprecated` association matcher is only available on Active Record >= 8.1.' expect do - expect(having_many_children(deprecated: false)).to have_many(:children).deprecated(false) + expect(having_many_children).to have_many(:children).deprecated end.to raise_error(NotImplementedError, expected_message) end end From 223977003d4ab2ef6065cc79e983f6cd1c3abfaa Mon Sep 17 00:00:00 2001 From: Stefanni Brasil Date: Wed, 19 Nov 2025 12:47:24 -0700 Subject: [PATCH 3/6] The method's return should be the last line in the docs --- .../active_record/association_matcher.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 2e847f96d..125057287 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -372,8 +372,6 @@ module ActiveRecord # should belong_to(:organization).optional # end # - # @return [AssociationMatcher] - # # ##### deprecated # # Use `deprecated` to assert that the `:deprecated` option was specified. @@ -393,6 +391,8 @@ module ActiveRecord # should belong_to(:bank).deprecated(true) # end # + # @return [AssociationMatcher] + # def belong_to(name) AssociationMatcher.new(:belongs_to, name) end @@ -700,8 +700,6 @@ def belong_to(name) # should have_delegated_type(:drivable).optional # end # - # @return [AssociationMatcher] - # # ##### deprecated # # Use `deprecated` to assert that the association is not allowed to be nil. @@ -721,6 +719,8 @@ def belong_to(name) # should have_delegated_type(:drivable).deprecated # end # + # @return [AssociationMatcher] + # def have_delegated_type(name) AssociationMatcher.new(:belongs_to, name) end @@ -1007,7 +1007,6 @@ def have_delegated_type(name) # should have_many(:employees).inverse_of(:company) # end # - # @return [AssociationMatcher] # # ##### deprecated # @@ -1028,6 +1027,8 @@ def have_delegated_type(name) # should have_many(:games).deprecated(true) # end # + # @return [AssociationMatcher] + # def have_many(name) AssociationMatcher.new(:has_many, name) end @@ -1273,7 +1274,6 @@ def have_many(name) # should have_one(:brain).required # end # - # @return [AssociationMatcher] # # ##### deprecated # @@ -1294,6 +1294,8 @@ def have_many(name) # should have_one(:bank).deprecated(true) # end # + # @return [AssociationMatcher] + # def have_one(name) AssociationMatcher.new(:has_one, name) end @@ -1450,8 +1452,6 @@ def have_one(name) # should have_and_belong_to_many(:advertisers).autosave(true) # end # - # @return [AssociationMatcher] - # # ##### deprecated # # Use `deprecated` to assert that the `:deprecated` option was specified. From 4ea3d9c50cbe652a549ee0524c6e1bf203586ceb Mon Sep 17 00:00:00 2001 From: Stefanni Brasil Date: Wed, 19 Nov 2025 12:56:26 -0700 Subject: [PATCH 4/6] Test error message for an association with a non-matching :deprecated option --- .../active_record/association_matcher.rb | 3 +- .../active_record/association_matcher_spec.rb | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 125057287..35b848feb 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -1959,7 +1959,8 @@ def deprecated_correct? if option_verifier.correct_for_boolean?(:deprecated, options[:deprecated]) true else - @missing = "#{name} should have deprecated: #{options[:deprecated]}" + @missing = "#{name} should have deprecated set to"\ + " #{options[:deprecated]}" false end end diff --git a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb index 9d2da58a8..fed9b0851 100644 --- a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb @@ -1387,6 +1387,18 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {}) expect(Author.new).to have_many(:paperbacks).source(:format).deprecated(false) expect(Author.new).not_to have_many(:paperbacks).source(:format).deprecated(true) end + + it 'rejects an association with a non-matching :deprecated option with the correct message' do + define_model :child, parent_id: :integer + define_model :parent do + has_many :children, deprecated: false + end + + message = 'Expected Parent to have a has_many association called children (children should have deprecated set to true)' + expect do + expect(Parent.new).to have_many(:children).deprecated(true) + end.to fail_with_message(message) + end end end @@ -2119,6 +2131,18 @@ def having_many_non_existent_class(model_name, assoc_name, options = {}) expect(Person.new).to have_one(:detail).through(:account).deprecated(false) expect(Person.new).not_to have_one(:detail).through(:account).deprecated(true) end + + it 'rejects an association with a non-matching :deprecated option with the correct message' do + define_model :detail, person_id: :integer, disabled: :boolean + define_model :person do + has_one :detail, deprecated: false + end + + message = 'Expected Person to have a has_one association called detail (detail should have deprecated set to true)' + expect do + expect(Person.new).to have_one(:detail).deprecated(true) + end.to fail_with_message(message) + end end it 'accepts an association with a through' do @@ -2833,6 +2857,20 @@ def having_one_non_existent(model_name, assoc_name, options = {}) expect(having_and_belonging_to_many_relatives). to have_and_belong_to_many(:relatives).deprecated(false) end + + it 'rejects an association with a non-matching :deprecated option with the correct message' do + define_model :relatives, adopted: :boolean + define_model :person do + has_and_belongs_to_many :relatives + end + define_model :people_relative, person_id: :integer, + relative_id: :integer + + message = 'Expected Person to have a has_and_belongs_to_many association called relatives (relatives should have deprecated set to true)' + expect do + expect(Person.new).to have_and_belong_to_many(:relatives).deprecated(true) + end.to fail_with_message(message) + end end end @@ -3100,6 +3138,14 @@ def having_and_belonging_to_many_non_existent_class(model_name, assoc_name, opti end end end + + it 'rejects an association with a non-matching :deprecated option with the correct message' do + message = 'Expected Vehicle to have a belongs_to association called drivable (drivable should have deprecated set to true)' + + expect do + expect(delegating_type_to_drivable(deprecated: false)).to have_delegated_type(:drivable).deprecated(true) + end.to fail_with_message(message) + end end context 'an association without a :deprecated option' do From bcba32b781749b80720e2352c37d78c2932bcc23 Mon Sep 17 00:00:00 2001 From: Stefanni Brasil Date: Wed, 19 Nov 2025 12:57:41 -0700 Subject: [PATCH 5/6] Leave the submatchers_match? as the last check, as they're usually a slower check --- lib/shoulda/matchers/active_record/association_matcher.rb | 4 ++-- lib/shoulda/matchers/rails_shim.rb | 4 ---- spec/support/unit/helpers/active_record_versions.rb | 4 ++++ spec/support/unit/helpers/rails_versions.rb | 8 -------- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 35b848feb..433806f96 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -1692,8 +1692,8 @@ def matches?(subject) touch_correct? && types_correct? && strict_loading_correct? && - submatchers_match? && - deprecated_correct? + deprecated_correct? && + submatchers_match? end def join_table_name diff --git a/lib/shoulda/matchers/rails_shim.rb b/lib/shoulda/matchers/rails_shim.rb index 0fd4daf61..1ca7c5a86 100644 --- a/lib/shoulda/matchers/rails_shim.rb +++ b/lib/shoulda/matchers/rails_shim.rb @@ -147,10 +147,6 @@ def validates_column_options? Gem::Requirement.new('>= 7.1.0').satisfied_by?(active_record_version) end - def active_record_lt_8_1? - Gem::Requirement.new('< 7').satisfied_by?(active_model_version) - end - private def simply_generate_validation_message( diff --git a/spec/support/unit/helpers/active_record_versions.rb b/spec/support/unit/helpers/active_record_versions.rb index c018b30fc..51fdac815 100644 --- a/spec/support/unit/helpers/active_record_versions.rb +++ b/spec/support/unit/helpers/active_record_versions.rb @@ -10,5 +10,9 @@ def self.configure_example_group(example_group) def active_record_version Tests::Version.new(::ActiveRecord::VERSION::STRING) end + + def active_record_gte_8_1? + active_record_version >= '8.1' + end end end diff --git a/spec/support/unit/helpers/rails_versions.rb b/spec/support/unit/helpers/rails_versions.rb index 503606692..4324f6fc6 100644 --- a/spec/support/unit/helpers/rails_versions.rb +++ b/spec/support/unit/helpers/rails_versions.rb @@ -11,10 +11,6 @@ def rails_version Tests::Version.new(Rails::VERSION::STRING) end - def active_record_version - Tests::Version.new(::ActiveModel::VERSION::STRING) - end - def rails_oldest_version_supported 7.1 end @@ -22,9 +18,5 @@ def rails_oldest_version_supported def rails_gt_8_0? rails_version >= '8.0' end - - def active_record_gte_8_1? - active_record_version >= '8.1' - end end end From 98a02ae53fff6aa8b719707a2e3c1e7ba24bb7b3 Mon Sep 17 00:00:00 2001 From: Stefanni Brasil Date: Thu, 27 Nov 2025 11:11:08 -0700 Subject: [PATCH 6/6] Fix typos and add missing test for belong_to --- .../active_record/association_matcher.rb | 2 +- .../active_record/association_matcher_spec.rb | 32 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 433806f96..dfe2188ce 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -702,7 +702,7 @@ def belong_to(name) # # ##### deprecated # - # Use `deprecated` to assert that the association is not allowed to be nil. + # Use `deprecated` to assert that the `:deprecated` option was specified. # (Enabled by default in Rails 8.1+). # # class Vehicle < ActiveRecord::Base diff --git a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb index fed9b0851..3d2690f4e 100644 --- a/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb @@ -532,7 +532,7 @@ end context 'when qualified with required(false)' do - it 'passes' do + it 'fails with an appropriate message' do assertion = lambda do expect(belonging_to_parent(required: true)). to belong_to(:parent).required(false) @@ -762,7 +762,7 @@ def ensure_parent_is_set end if active_record_gte_8_1? - context 'an association with a :touch option' do + context 'an association with a :deprecated option' do [false, true].each do |deprecated_value| context "when the model has deprecated: #{deprecated_value}" do it 'accepts a matching deprecated option' do @@ -805,6 +805,18 @@ def ensure_parent_is_set expect(belonging_to_parent).not_to belong_to(:parent).deprecated end end + + it 'rejects an association with a non-matching :deprecated option with the correct message' do + define_model :parent, adopter: :boolean + define_model :child, parent_id: :integer do + belongs_to :parent, deprecated: false + end + + message = 'Expected Child to have a belongs_to association called parent (parent should have deprecated set to true)' + expect do + expect(Child.new).to belong_to(:parent).deprecated(true) + end.to fail_with_message(message) + end end def belonging_to_parent(options = {}, parent_options = {}, &block) @@ -3138,14 +3150,6 @@ def having_and_belonging_to_many_non_existent_class(model_name, assoc_name, opti end end end - - it 'rejects an association with a non-matching :deprecated option with the correct message' do - message = 'Expected Vehicle to have a belongs_to association called drivable (drivable should have deprecated set to true)' - - expect do - expect(delegating_type_to_drivable(deprecated: false)).to have_delegated_type(:drivable).deprecated(true) - end.to fail_with_message(message) - end end context 'an association without a :deprecated option' do @@ -3161,6 +3165,14 @@ def having_and_belonging_to_many_non_existent_class(model_name, assoc_name, opti expect(delegating_type_to_drivable).not_to have_delegated_type(:drivable).deprecated end end + + it 'rejects an association with a non-matching :deprecated option with the correct message' do + message = 'Expected Vehicle to have a belongs_to association called drivable (drivable should have deprecated set to true)' + + expect do + expect(delegating_type_to_drivable(deprecated: false)).to have_delegated_type(:drivable).deprecated(true) + end.to fail_with_message(message) + end end context 'given the association is neither configured to be required nor optional' do