Skip to content

Commit

Permalink
feat: Add support for foreign_type qualifier on AssociationMatcher (
Browse files Browse the repository at this point in the history
#1609)

* Add support for foreign_type

* fix: Small adjusments on `foreign_type` qualifier

---------

Co-authored-by: mswiszcz <mateusz@swiszcz.pl>
  • Loading branch information
matsales28 and mswiszcz committed Mar 15, 2024
1 parent 6fb56db commit efd1b39
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 7 deletions.
125 changes: 121 additions & 4 deletions lib/shoulda/matchers/active_record/association_matcher.rb
Expand Up @@ -144,6 +144,28 @@ module ActiveRecord
# with_foreign_key('country_id')
# end
#
# ##### with_foreign_type
#
# Use `with_foreign_type` to test usage of the `:foreign_type` option.
#
# class Visitor < ActiveRecord::Base
# belongs_to :location, foreign_type: 'facility_type', polymorphic: true
# end
#
# # RSpec
# RSpec.describe Visitor, type: :model do
# it do
# should belong_to(:location).
# with_foreign_type('facility_type')
# end
# end
#
# # Minitest (Shoulda)
# class VisitorTest < ActiveSupport::TestCase
# should belong_to(:location).
# with_foreign_type('facility_type')
# end
#
# ##### dependent
#
# Use `dependent` to assert that the `:dependent` option was specified.
Expand Down Expand Up @@ -795,6 +817,24 @@ def have_delegated_type(name)
# should have_many(:worries).with_foreign_key('worrier_id')
# end
#
# ##### with_foreign_type
#
# Use `with_foreign_type` to test usage of the `:foreign_type` option.
#
# class Hotel < ActiveRecord::Base
# has_many :visitors, foreign_key: 'facility_type', as: :location
# end
#
# # RSpec
# RSpec.describe Hotel, type: :model do
# it { should have_many(:visitors).with_foreign_type('facility_type') }
# end
#
# # Minitest (Shoulda)
# class HotelTest < ActiveSupport::TestCase
# should have_many(:visitors).with_foreign_type('facility_type')
# end
#
# ##### dependent
#
# Use `dependent` to assert that the `:dependent` option was specified.
Expand Down Expand Up @@ -1066,6 +1106,24 @@ def have_many(name)
# should have_one(:job).with_foreign_key('worker_id')
# end
#
# ##### with_foreign_type
#
# Use `with_foreign_type` to test usage of the `:foreign_type` option.
#
# class Hotel < ActiveRecord::Base
# has_one :special_guest, foreign_type: 'facility_type', as: :location
# end
#
# # RSpec
# RSpec.describe Hotel, type: :model do
# it { should have_one(:special_guest).with_foreign_type('facility_type') }
# end
#
# # Minitest (Shoulda)
# class HotelTest < ActiveSupport::TestCase
# should have_one(:special_guest).with_foreign_type('facility_type')
# end
#
# ##### through
#
# Use `through` to test usage of the `:through` option. This asserts that
Expand Down Expand Up @@ -1433,6 +1491,11 @@ def with_foreign_key(foreign_key)
self
end

def with_foreign_type(foreign_type)
@options[:foreign_type] = foreign_type
self
end

def with_primary_key(primary_key)
@options[:primary_key] = primary_key
self
Expand Down Expand Up @@ -1510,6 +1573,7 @@ def matches?(subject)
macro_correct? &&
validate_inverse_of_through_association &&
(polymorphic? || class_exists?) &&
foreign_type_matches? &&
foreign_key_exists? &&
primary_key_exists? &&
query_constraints_exists? &&
Expand Down Expand Up @@ -1617,14 +1681,24 @@ def validate_inverse_of_through_association
end

def macro_is_not_through?
macro == :belongs_to ||
([:has_many, :has_one].include?(macro) && !through?)
macro == :belongs_to || has_association_not_through?
end

def has_association_not_through?
[:has_many, :has_one].include?(macro) && !through?
end

def foreign_key_exists?
!(belongs_foreign_key_missing? || has_foreign_key_missing?)
end

def foreign_type_matches?
!options.key?(:foreign_type) || (
!belongs_foreign_type_missing? &&
!has_foreign_type_missing?
)
end

def primary_key_exists?
!macro_is_not_through? || primary_key_correct?(model_class)
end
Expand All @@ -1651,12 +1725,20 @@ def belongs_foreign_key_missing?
macro == :belongs_to && !class_has_foreign_key?(model_class)
end

def belongs_foreign_type_missing?
macro == :belongs_to && !class_has_foreign_type?(model_class)
end

def has_foreign_key_missing?
[:has_many, :has_one].include?(macro) &&
!through? &&
has_association_not_through? &&
!class_has_foreign_key?(associated_class)
end

def has_foreign_type_missing?
has_association_not_through? &&
!class_has_foreign_type?(associated_class)
end

def class_name_correct?
if options.key?(:class_name)
if option_verifier.correct_for_constant?(
Expand Down Expand Up @@ -1819,6 +1901,22 @@ def validate_foreign_key(klass)
end
end

def class_has_foreign_type?(klass)
if options.key?(:foreign_type) && !foreign_type_correct?
@missing = foreign_type_failure_message(
klass,
options[:foreign_type],
)

false
elsif !has_column?(klass, foreign_type)
@missing = foreign_type_failure_message(klass, foreign_type)
false
else
true
end
end

def has_column?(klass, column)
case column
when Array
Expand All @@ -1835,10 +1933,21 @@ def foreign_key_correct?
)
end

def foreign_type_correct?
option_verifier.correct_for_string?(
:foreign_type,
options[:foreign_type],
)
end

def foreign_key_failure_message(klass, foreign_key)
"#{klass} does not have a #{foreign_key} foreign key."
end

def foreign_type_failure_message(klass, foreign_type)
"#{klass} does not have a #{foreign_type} foreign type."
end

def primary_key_correct?(klass)
if options.key?(:primary_key)
if option_verifier.correct_for_string?(
Expand Down Expand Up @@ -1881,6 +1990,14 @@ def foreign_key_reflection
end
end

def foreign_type
if [:has_one, :has_many].include?(macro)
reflection.type
else
reflection.foreign_type
end
end

def submatchers_match?
failing_submatchers.empty?
end
Expand Down
Expand Up @@ -8,6 +8,7 @@ class ModelReflector
:associated_class,
:association_foreign_key,
:foreign_key,
:foreign_type,
:has_and_belongs_to_many_name,
:join_table_name,
:polymorphic?,
Expand Down
Expand Up @@ -31,6 +31,17 @@
expect(Child.new).to belong_to(:parent)
end

it 'accepts an association with an existing custom foreign key and type' do
define_model :parent
define_model :child, ancestor_id: :integer, ancestor_type: :string do
belongs_to :parent, polymorphic: true, foreign_key: 'ancestor_id', foreign_type: 'ancestor_type'
end

expect(Child.new).to belong_to(:parent).
with_foreign_key(:ancestor_id).
with_foreign_type(:ancestor_type)
end

it 'accepts an association using an existing custom primary key' do
define_model :parent
define_model :child, parent_id: :integer, custom_primary_key: :integer do
Expand Down Expand Up @@ -1176,6 +1187,18 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {})
expect(Parent.new).to have_many(:children)
end

it 'accepts an association with a nonstandard reverse foreign type, using :inverse_of' do
define_model :visitor, location_id: :integer, facility_type: :string do
belongs_to :location, foreign_type: :facility_type, inverse_of: :visitors, polymorphic: true
end

define_model :hotel do
has_many :visitors, inverse_of: :location, foreign_type: :facility_type, as: :location
end

expect(Hotel.new).to have_many(:visitors).with_foreign_type(:facility_type)
end

it 'rejects an association with a nonstandard reverse foreign key, if :inverse_of is not correct' do
define_model :child, mother_id: :integer do
belongs_to :mother, inverse_of: :children, class_name: :Parent
Expand All @@ -1189,16 +1212,24 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {})
end

it 'accepts an association with a nonstandard foreign key, with reverse association turned off' do
define_model :child, ancestor_id: :integer do
end

define_model :child, ancestor_id: :integer
define_model :parent do
has_many :children, foreign_key: :ancestor_id, inverse_of: false
end

expect(Parent.new).to have_many(:children)
end

it 'accepts an association with a nonstandard type, with reverse association turned off' do
define_model :visitor, location_id: :integer, facility_type: :string

define_model :hotel do
has_many :visitors, foreign_type: :facility_type, inverse_of: false, as: :location
end

expect(Hotel.new).to have_many(:visitors).with_foreign_type(:facility_type)
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
Expand Down Expand Up @@ -1655,6 +1686,21 @@ def having_many_non_existent_class(model_name, assoc_name, options = {})
expect(Person.new).to have_one(:detail).with_foreign_key(:detailed_person_id)
end

it 'accepts an association with an existing custom foreign type' do
define_model :profile, user_id: :integer, related_user_type: :string

define_model :admin do
has_one :profile, foreign_type: :related_user_type, as: :user
end

define_model :moderator do
has_one :profile, foreign_type: :related_user_type, as: :user
end

expect(Admin.new).to have_one(:profile).with_foreign_type(:related_user_type)
expect(Moderator.new).to have_one(:profile).with_foreign_type(:related_user_type)
end

it 'accepts an association using an existing custom primary key' do
define_model :detail, person_id: :integer
define_model :person, custom_primary_key: :integer do
Expand Down

0 comments on commit efd1b39

Please sign in to comment.