Skip to content

Commit efd1b39

Browse files
matsales28mswiszcz
andauthored
feat: Add support for foreign_type qualifier on AssociationMatcher (#1609)
* Add support for foreign_type * fix: Small adjusments on `foreign_type` qualifier --------- Co-authored-by: mswiszcz <[email protected]>
1 parent 6fb56db commit efd1b39

File tree

3 files changed

+171
-7
lines changed

3 files changed

+171
-7
lines changed

lib/shoulda/matchers/active_record/association_matcher.rb

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ module ActiveRecord
144144
# with_foreign_key('country_id')
145145
# end
146146
#
147+
# ##### with_foreign_type
148+
#
149+
# Use `with_foreign_type` to test usage of the `:foreign_type` option.
150+
#
151+
# class Visitor < ActiveRecord::Base
152+
# belongs_to :location, foreign_type: 'facility_type', polymorphic: true
153+
# end
154+
#
155+
# # RSpec
156+
# RSpec.describe Visitor, type: :model do
157+
# it do
158+
# should belong_to(:location).
159+
# with_foreign_type('facility_type')
160+
# end
161+
# end
162+
#
163+
# # Minitest (Shoulda)
164+
# class VisitorTest < ActiveSupport::TestCase
165+
# should belong_to(:location).
166+
# with_foreign_type('facility_type')
167+
# end
168+
#
147169
# ##### dependent
148170
#
149171
# Use `dependent` to assert that the `:dependent` option was specified.
@@ -795,6 +817,24 @@ def have_delegated_type(name)
795817
# should have_many(:worries).with_foreign_key('worrier_id')
796818
# end
797819
#
820+
# ##### with_foreign_type
821+
#
822+
# Use `with_foreign_type` to test usage of the `:foreign_type` option.
823+
#
824+
# class Hotel < ActiveRecord::Base
825+
# has_many :visitors, foreign_key: 'facility_type', as: :location
826+
# end
827+
#
828+
# # RSpec
829+
# RSpec.describe Hotel, type: :model do
830+
# it { should have_many(:visitors).with_foreign_type('facility_type') }
831+
# end
832+
#
833+
# # Minitest (Shoulda)
834+
# class HotelTest < ActiveSupport::TestCase
835+
# should have_many(:visitors).with_foreign_type('facility_type')
836+
# end
837+
#
798838
# ##### dependent
799839
#
800840
# Use `dependent` to assert that the `:dependent` option was specified.
@@ -1066,6 +1106,24 @@ def have_many(name)
10661106
# should have_one(:job).with_foreign_key('worker_id')
10671107
# end
10681108
#
1109+
# ##### with_foreign_type
1110+
#
1111+
# Use `with_foreign_type` to test usage of the `:foreign_type` option.
1112+
#
1113+
# class Hotel < ActiveRecord::Base
1114+
# has_one :special_guest, foreign_type: 'facility_type', as: :location
1115+
# end
1116+
#
1117+
# # RSpec
1118+
# RSpec.describe Hotel, type: :model do
1119+
# it { should have_one(:special_guest).with_foreign_type('facility_type') }
1120+
# end
1121+
#
1122+
# # Minitest (Shoulda)
1123+
# class HotelTest < ActiveSupport::TestCase
1124+
# should have_one(:special_guest).with_foreign_type('facility_type')
1125+
# end
1126+
#
10691127
# ##### through
10701128
#
10711129
# Use `through` to test usage of the `:through` option. This asserts that
@@ -1433,6 +1491,11 @@ def with_foreign_key(foreign_key)
14331491
self
14341492
end
14351493

1494+
def with_foreign_type(foreign_type)
1495+
@options[:foreign_type] = foreign_type
1496+
self
1497+
end
1498+
14361499
def with_primary_key(primary_key)
14371500
@options[:primary_key] = primary_key
14381501
self
@@ -1510,6 +1573,7 @@ def matches?(subject)
15101573
macro_correct? &&
15111574
validate_inverse_of_through_association &&
15121575
(polymorphic? || class_exists?) &&
1576+
foreign_type_matches? &&
15131577
foreign_key_exists? &&
15141578
primary_key_exists? &&
15151579
query_constraints_exists? &&
@@ -1617,14 +1681,24 @@ def validate_inverse_of_through_association
16171681
end
16181682

16191683
def macro_is_not_through?
1620-
macro == :belongs_to ||
1621-
([:has_many, :has_one].include?(macro) && !through?)
1684+
macro == :belongs_to || has_association_not_through?
1685+
end
1686+
1687+
def has_association_not_through?
1688+
[:has_many, :has_one].include?(macro) && !through?
16221689
end
16231690

16241691
def foreign_key_exists?
16251692
!(belongs_foreign_key_missing? || has_foreign_key_missing?)
16261693
end
16271694

1695+
def foreign_type_matches?
1696+
!options.key?(:foreign_type) || (
1697+
!belongs_foreign_type_missing? &&
1698+
!has_foreign_type_missing?
1699+
)
1700+
end
1701+
16281702
def primary_key_exists?
16291703
!macro_is_not_through? || primary_key_correct?(model_class)
16301704
end
@@ -1651,12 +1725,20 @@ def belongs_foreign_key_missing?
16511725
macro == :belongs_to && !class_has_foreign_key?(model_class)
16521726
end
16531727

1728+
def belongs_foreign_type_missing?
1729+
macro == :belongs_to && !class_has_foreign_type?(model_class)
1730+
end
1731+
16541732
def has_foreign_key_missing?
1655-
[:has_many, :has_one].include?(macro) &&
1656-
!through? &&
1733+
has_association_not_through? &&
16571734
!class_has_foreign_key?(associated_class)
16581735
end
16591736

1737+
def has_foreign_type_missing?
1738+
has_association_not_through? &&
1739+
!class_has_foreign_type?(associated_class)
1740+
end
1741+
16601742
def class_name_correct?
16611743
if options.key?(:class_name)
16621744
if option_verifier.correct_for_constant?(
@@ -1819,6 +1901,22 @@ def validate_foreign_key(klass)
18191901
end
18201902
end
18211903

1904+
def class_has_foreign_type?(klass)
1905+
if options.key?(:foreign_type) && !foreign_type_correct?
1906+
@missing = foreign_type_failure_message(
1907+
klass,
1908+
options[:foreign_type],
1909+
)
1910+
1911+
false
1912+
elsif !has_column?(klass, foreign_type)
1913+
@missing = foreign_type_failure_message(klass, foreign_type)
1914+
false
1915+
else
1916+
true
1917+
end
1918+
end
1919+
18221920
def has_column?(klass, column)
18231921
case column
18241922
when Array
@@ -1835,10 +1933,21 @@ def foreign_key_correct?
18351933
)
18361934
end
18371935

1936+
def foreign_type_correct?
1937+
option_verifier.correct_for_string?(
1938+
:foreign_type,
1939+
options[:foreign_type],
1940+
)
1941+
end
1942+
18381943
def foreign_key_failure_message(klass, foreign_key)
18391944
"#{klass} does not have a #{foreign_key} foreign key."
18401945
end
18411946

1947+
def foreign_type_failure_message(klass, foreign_type)
1948+
"#{klass} does not have a #{foreign_type} foreign type."
1949+
end
1950+
18421951
def primary_key_correct?(klass)
18431952
if options.key?(:primary_key)
18441953
if option_verifier.correct_for_string?(
@@ -1881,6 +1990,14 @@ def foreign_key_reflection
18811990
end
18821991
end
18831992

1993+
def foreign_type
1994+
if [:has_one, :has_many].include?(macro)
1995+
reflection.type
1996+
else
1997+
reflection.foreign_type
1998+
end
1999+
end
2000+
18842001
def submatchers_match?
18852002
failing_submatchers.empty?
18862003
end

lib/shoulda/matchers/active_record/association_matchers/model_reflector.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class ModelReflector
88
:associated_class,
99
:association_foreign_key,
1010
:foreign_key,
11+
:foreign_type,
1112
:has_and_belongs_to_many_name,
1213
:join_table_name,
1314
:polymorphic?,

spec/unit/shoulda/matchers/active_record/association_matcher_spec.rb

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@
3131
expect(Child.new).to belong_to(:parent)
3232
end
3333

34+
it 'accepts an association with an existing custom foreign key and type' do
35+
define_model :parent
36+
define_model :child, ancestor_id: :integer, ancestor_type: :string do
37+
belongs_to :parent, polymorphic: true, foreign_key: 'ancestor_id', foreign_type: 'ancestor_type'
38+
end
39+
40+
expect(Child.new).to belong_to(:parent).
41+
with_foreign_key(:ancestor_id).
42+
with_foreign_type(:ancestor_type)
43+
end
44+
3445
it 'accepts an association using an existing custom primary key' do
3546
define_model :parent
3647
define_model :child, parent_id: :integer, custom_primary_key: :integer do
@@ -1176,6 +1187,18 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {})
11761187
expect(Parent.new).to have_many(:children)
11771188
end
11781189

1190+
it 'accepts an association with a nonstandard reverse foreign type, using :inverse_of' do
1191+
define_model :visitor, location_id: :integer, facility_type: :string do
1192+
belongs_to :location, foreign_type: :facility_type, inverse_of: :visitors, polymorphic: true
1193+
end
1194+
1195+
define_model :hotel do
1196+
has_many :visitors, inverse_of: :location, foreign_type: :facility_type, as: :location
1197+
end
1198+
1199+
expect(Hotel.new).to have_many(:visitors).with_foreign_type(:facility_type)
1200+
end
1201+
11791202
it 'rejects an association with a nonstandard reverse foreign key, if :inverse_of is not correct' do
11801203
define_model :child, mother_id: :integer do
11811204
belongs_to :mother, inverse_of: :children, class_name: :Parent
@@ -1189,16 +1212,24 @@ def belonging_to_non_existent_class(model_name, assoc_name, options = {})
11891212
end
11901213

11911214
it 'accepts an association with a nonstandard foreign key, with reverse association turned off' do
1192-
define_model :child, ancestor_id: :integer do
1193-
end
1194-
1215+
define_model :child, ancestor_id: :integer
11951216
define_model :parent do
11961217
has_many :children, foreign_key: :ancestor_id, inverse_of: false
11971218
end
11981219

11991220
expect(Parent.new).to have_many(:children)
12001221
end
12011222

1223+
it 'accepts an association with a nonstandard type, with reverse association turned off' do
1224+
define_model :visitor, location_id: :integer, facility_type: :string
1225+
1226+
define_model :hotel do
1227+
has_many :visitors, foreign_type: :facility_type, inverse_of: false, as: :location
1228+
end
1229+
1230+
expect(Hotel.new).to have_many(:visitors).with_foreign_type(:facility_type)
1231+
end
1232+
12021233
describe 'strict_loading' do
12031234
context 'when the application is configured with strict_loading disabled by default' do
12041235
it 'accepts an association with a matching :strict_loading option' do
@@ -1655,6 +1686,21 @@ def having_many_non_existent_class(model_name, assoc_name, options = {})
16551686
expect(Person.new).to have_one(:detail).with_foreign_key(:detailed_person_id)
16561687
end
16571688

1689+
it 'accepts an association with an existing custom foreign type' do
1690+
define_model :profile, user_id: :integer, related_user_type: :string
1691+
1692+
define_model :admin do
1693+
has_one :profile, foreign_type: :related_user_type, as: :user
1694+
end
1695+
1696+
define_model :moderator do
1697+
has_one :profile, foreign_type: :related_user_type, as: :user
1698+
end
1699+
1700+
expect(Admin.new).to have_one(:profile).with_foreign_type(:related_user_type)
1701+
expect(Moderator.new).to have_one(:profile).with_foreign_type(:related_user_type)
1702+
end
1703+
16581704
it 'accepts an association using an existing custom primary key' do
16591705
define_model :detail, person_id: :integer
16601706
define_model :person, custom_primary_key: :integer do

0 commit comments

Comments
 (0)