diff -Nur a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb --- a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb 2022-03-02 17:38:38.274324841 +0800 +++ b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb 2022-03-02 17:48:15.220014811 +0800 @@ -562,7 +562,8 @@ def has_been_qualified? @submatchers.any? do |submatcher| - submatcher.class.parent == NumericalityMatchers + Shoulda::Matchers::RailsShim.parent_of(submatcher.class) == + NumericalityMatchers end end diff -Nur a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb --- a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb 2022-03-02 17:38:38.274324841 +0800 +++ b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb 2022-03-02 17:48:15.220014811 +0800 @@ -134,9 +134,8 @@ private def secure_password_being_validated? - defined?(::ActiveModel::SecurePassword) && - @subject.class.ancestors.include?(::ActiveModel::SecurePassword::InstanceMethodsOnActivation) && - @attribute == :password + Shoulda::Matchers::RailsShim.digestible_attributes_in(@subject). + include?(@attribute) end def disallows_and_double_checks_value_of!(value, message) diff -Nur a/lib/shoulda/matchers/active_record/association_matcher.rb b/lib/shoulda/matchers/active_record/association_matcher.rb --- a/lib/shoulda/matchers/active_record/association_matcher.rb 2022-03-02 17:38:38.278324908 +0800 +++ b/lib/shoulda/matchers/active_record/association_matcher.rb 2022-03-02 17:57:16.205124877 +0800 @@ -1182,13 +1182,11 @@ def class_has_foreign_key?(klass) if options.key?(:foreign_key) option_verifier.correct_for_string?(:foreign_key, options[:foreign_key]) + elsif column_names_for(klass).include?(foreign_key) + true else - if klass.column_names.include?(foreign_key) - true - else - @missing = "#{klass} does not have a #{foreign_key} foreign key." - false - end + @missing = "#{klass} does not have a #{foreign_key} foreign key." + false end end @@ -1226,6 +1224,11 @@ def submatchers_match? failing_submatchers.empty? end + def column_names_for(klass) + klass.column_names + rescue ::ActiveRecord::StatementInvalid + [] + end end end end diff -Nur a/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb b/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb --- a/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb 2022-03-02 17:38:38.278324908 +0800 +++ b/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb 2022-03-02 17:48:15.224014878 +0800 @@ -502,10 +502,11 @@ end def ensure_secure_password_set(instance) - if has_secure_password? - instance.password = "password" - instance.password_confirmation = "password" - end + Shoulda::Matchers::RailsShim.digestible_attributes_in(instance). + each do |attribute| + instance.send("#{attribute}=", 'password') + instance.send("#{attribute}_confirmation=", 'password') + end end def update_existing_record!(value) @@ -529,9 +530,7 @@ end def has_secure_password? - model.ancestors.map(&:to_s).include?( - 'ActiveModel::SecurePassword::InstanceMethodsOnActivation' - ) + Shoulda::Matchers::RailsShim.has_secure_password?(subject, @attribute) end def build_new_record diff -Nur a/lib/shoulda/matchers/rails_shim.rb b/lib/shoulda/matchers/rails_shim.rb --- a/lib/shoulda/matchers/rails_shim.rb 2022-03-02 17:38:38.274324841 +0800 +++ b/lib/shoulda/matchers/rails_shim.rb 2022-03-02 17:48:15.224014878 +0800 @@ -107,6 +107,43 @@ end end + def parent_of(mod) + if mod.respond_to?(:module_parent) + mod.module_parent + else + mod.parent + end + end + + def has_secure_password?(record, attribute_name) + if secure_password_module + attribute_name == :password && + record.class.ancestors.include?(secure_password_module) + else + record.respond_to?("authenticate_#{attribute_name}") + end + end + + def digestible_attributes_in(record) + record.methods.inject([]) do |array, method_name| + match = method_name.to_s.match( + /\A(\w+)_(?:confirmation|digest)=\Z/, + ) + + if match + array.concat([match[1].to_sym]) + else + array + end + end + end + + def secure_password_module + ::ActiveModel::SecurePassword::InstanceMethodsOnActivation + rescue NameError + nil + end + private def simply_generate_validation_message( diff -Nur a/spec/support/unit/helpers/class_builder.rb b/spec/support/unit/helpers/class_builder.rb --- a/spec/support/unit/helpers/class_builder.rb 2022-03-02 17:38:38.270324774 +0800 +++ b/spec/support/unit/helpers/class_builder.rb 2022-03-02 17:48:15.224014878 +0800 @@ -18,18 +18,15 @@ end def reset - remove_defined_classes + remove_defined_modules + defined_modules.clear end def define_module(module_name, &block) module_name = module_name.to_s.camelize + namespace, name_without_namespace = parse_constant_name(module_name) - namespace, name_without_namespace = - ClassBuilder.parse_constant_name(module_name) - - if namespace.const_defined?(name_without_namespace, false) - namespace.__send__(:remove_const, name_without_namespace) - end + remove_defined_module(module_name) eval <<-RUBY module #{namespace}::#{name_without_namespace} @@ -38,6 +35,7 @@ namespace.const_get(name_without_namespace).tap do |constant| constant.unloadable + @_defined_modules = defined_modules | [constant] if block constant.module_eval(&block) @@ -47,13 +45,9 @@ def define_class(class_name, parent_class = Object, &block) class_name = class_name.to_s.camelize + namespace, name_without_namespace = parse_constant_name(class_name) - namespace, name_without_namespace = - ClassBuilder.parse_constant_name(class_name) - - if namespace.const_defined?(name_without_namespace, false) - namespace.__send__(:remove_const, name_without_namespace) - end + remove_defined_module(class_name) eval <<-RUBY class #{namespace}::#{name_without_namespace} < ::#{parent_class} @@ -62,6 +56,7 @@ namespace.const_get(name_without_namespace).tap do |constant| constant.unloadable + @_defined_modules = defined_modules | [constant] if block if block.arity == 0 @@ -82,8 +77,21 @@ private - def remove_defined_classes - ::ActiveSupport::Dependencies.clear + def remove_defined_modules + defined_modules.reverse_each { |mod| remove_defined_module(mod.name) } + ActiveSupport::Dependencies.clear + end + + def remove_defined_module(module_name) + namespace, name_without_namespace = parse_constant_name(module_name) + + if namespace.const_defined?(name_without_namespace, false) + namespace.__send__(:remove_const, name_without_namespace) + end + end + + def defined_modules + @_defined_modules ||= [] end end end diff -Nur a/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb --- a/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb 2022-03-02 17:38:38.262324640 +0800 +++ b/spec/unit/shoulda/matchers/active_model/have_secure_password_matcher_spec.rb 2022-03-02 18:04:41.336620792 +0800 @@ -1,20 +1,18 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveModel::HaveSecurePasswordMatcher, type: :model do - if active_model_3_1? - it 'matches when the subject configures has_secure_password with default options' do - working_model = define_model(:example, password_digest: :string) { has_secure_password } - expect(working_model.new).to have_secure_password - end + it 'matches when the subject configures has_secure_password with default options' do + working_model = define_model(:example, password_digest: :string) { has_secure_password } + expect(working_model.new).to have_secure_password + end - it 'does not match when the subject does not authenticate a password' do - no_secure_password = define_model(:example) - expect(no_secure_password.new).not_to have_secure_password - end + it 'does not match when the subject does not authenticate a password' do + no_secure_password = define_model(:example) + expect(no_secure_password.new).not_to have_secure_password + end - it 'does not match when the subject is missing the password_digest attribute' do - no_digest_column = define_model(:example) { has_secure_password } - expect(no_digest_column.new).not_to have_secure_password - end + it 'does not match when the subject is missing the password_digest attribute' do + no_digest_column = define_model(:example) { has_secure_password } + expect(no_digest_column.new).not_to have_secure_password end end