diff --git a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb index 1ce34cdfb..433806f96 100644 --- a/lib/shoulda/matchers/active_record/association_matcher.rb +++ b/lib/shoulda/matchers/active_record/association_matcher.rb @@ -372,6 +372,25 @@ module ActiveRecord # should belong_to(:organization).optional # end # + # ##### 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 + # # @return [AssociationMatcher] # def belong_to(name) @@ -681,9 +700,27 @@ def belong_to(name) # should have_delegated_type(:drivable).optional # end # + # ##### 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 + # # @return [AssociationMatcher] # - def have_delegated_type(name) AssociationMatcher.new(:belongs_to, name) end @@ -970,6 +1007,26 @@ def have_delegated_type(name) # should have_many(:employees).inverse_of(:company) # end # + # + # ##### 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 + # # @return [AssociationMatcher] # def have_many(name) @@ -1217,6 +1274,26 @@ def have_many(name) # should have_one(:brain).required # end # + # + # ##### 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 + # # @return [AssociationMatcher] # def have_one(name) @@ -1375,6 +1452,25 @@ def have_one(name) # should have_and_belong_to_many(:advertisers).autosave(true) # end # + # ##### 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) @@ -1546,6 +1642,16 @@ def join_table(join_table_name) self end + def deprecated(deprecated = true) + if Shoulda::Matchers::RailsShim.active_record_gte_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,6 +1692,7 @@ def matches?(subject) touch_correct? && types_correct? && strict_loading_correct? && + deprecated_correct? && submatchers_match? end @@ -1848,6 +1955,16 @@ def touch_correct? end end + def deprecated_correct? + if option_verifier.correct_for_boolean?(:deprecated, options[:deprecated]) + true + else + @missing = "#{name} should have deprecated set to"\ + " #{options[:deprecated]}" + false + end + end + def types_correct? if options.key?(:types) types = options[:types] diff --git a/lib/shoulda/matchers/rails_shim.rb b/lib/shoulda/matchers/rails_shim.rb index 09bd41d11..1ca7c5a86 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, 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/unit/shoulda/matchers/active_record/association_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb index b75677704..fed9b0851 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,64 @@ def ensure_parent_is_set end end + 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 + 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 +859,18 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {}) end context 'have_many' do + 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).to have_many(:children).deprecated + 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 +1322,86 @@ 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 + + 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 + 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 +2072,79 @@ 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 + + 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 define_model :detail @@ -2613,6 +2836,44 @@ 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 + + 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 + def having_and_belonging_to_many_relatives(_options = {}) define_model :relative define_model :people_relative, id: false, person_id: :integer, @@ -2848,6 +3109,60 @@ 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 + + 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 + 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