Skip to content

Commit

Permalink
Add required/optional qual's to belongs_to/has_one
Browse files Browse the repository at this point in the history
Rails 5 made two changes to `belongs_to` associations:

* `required` and `optional` were added as options (which add and remove
  a presence validation on the association, respectively)
* `required` was made the default

In addition, a `required` option was also added to `has_one`.

These new qualifiers allow us to test these options appropriately.

Credit: Shia <rise.shia@gmail.com>
  • Loading branch information
mcmire committed Oct 7, 2017
1 parent b310986 commit 3af3d9f
Show file tree
Hide file tree
Showing 9 changed files with 375 additions and 32 deletions.
9 changes: 9 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ is now:
* *PRs: [#989], [#964], [#917]*
* *Original issue: [#867]*

### Features

* Add `required` and `optional` qualifiers to `belong_to` and `have_one`
matchers. (When using the `belong_to` matcher under Rails 5+, `required` is
assumed unless overridden.)

* *Original PR: [#956]*
* *Original issues: [#870], [#861]*

[a6d09aa]: https://github.com/thoughtbot/shoulda-matchers/commit/a6d09aa5de0d546367e7b3d7177dfde6c66f7f05
[#943]: https://github.com/thoughtbot/shoulda-matchers/pulls/943
[#933]: https://github.com/thoughtbot/shoulda-matchers/issues/933
Expand Down
2 changes: 2 additions & 0 deletions lib/shoulda/matchers/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
require "shoulda/matchers/active_record/association_matchers/order_matcher"
require "shoulda/matchers/active_record/association_matchers/through_matcher"
require "shoulda/matchers/active_record/association_matchers/dependent_matcher"
require "shoulda/matchers/active_record/association_matchers/required_matcher"
require "shoulda/matchers/active_record/association_matchers/optional_matcher"
require "shoulda/matchers/active_record/association_matchers/source_matcher"
require "shoulda/matchers/active_record/association_matchers/model_reflector"
require "shoulda/matchers/active_record/association_matchers/model_reflection"
Expand Down
145 changes: 129 additions & 16 deletions lib/shoulda/matchers/active_record/association_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,48 @@ module ActiveRecord
#
# @return [AssociationMatcher]
#
# ##### required
#
# Use `required` to assert that the association is not allowed to be nil.
# (Enabled by default in Rails 5+.)
#
# class Person < ActiveRecord::Base
# belongs_to :organization, required: true
# end
#
# # RSpec
# describe Person
# it { should belong_to(:organization).required }
# end
#
# # Minitest (Shoulda)
# class PersonTest < ActiveSupport::TestCase
# should belong_to(:organization).required
# end
#
# @return [AssociationMatcher]
#
# ##### optional
#
# Use `optional` to assert that the association is allowed to be nil.
# (Rails 5+ only.)
#
# class Person < ActiveRecord::Base
# belongs_to :organization, optional: true
# end
#
# # RSpec
# describe Person
# it { should belong_to(:organization).optional }
# end
#
# # Minitest (Shoulda)
# class PersonTest < ActiveSupport::TestCase
# should belong_to(:organization).optional
# end
#
# @return [AssociationMatcher]
#
def belong_to(name)
AssociationMatcher.new(:belongs_to, name)
end
Expand Down Expand Up @@ -714,6 +756,25 @@ def have_many(name)
# should have_one(:bank).autosave(true)
# end
#
# ##### required
#
# Use `required` to assert that the association is not allowed to be nil.
# (Rails 5+ only.)
#
# class Person < ActiveRecord::Base
# has_one :brain, required: true
# end
#
# # RSpec
# describe Person
# it { should have_one(:brain).required }
# end
#
# # Minitest (Shoulda)
# class PersonTest < ActiveSupport::TestCase
# should have_one(:brain).required
# end
#
# @return [AssociationMatcher]
#
def have_one(name)
Expand Down Expand Up @@ -891,42 +952,67 @@ def initialize(macro, name)
@options = {}
@submatchers = []
@missing = ''

if macro == :belongs_to
if RailsShim.active_record_gte_5?
required
else
optional
end
end
end

def through(through)
through_matcher = AssociationMatchers::ThroughMatcher.new(through, name)
add_submatcher(through_matcher)
add_submatcher(
AssociationMatchers::ThroughMatcher,
through,
name,
)
self
end

def dependent(dependent)
dependent_matcher = AssociationMatchers::DependentMatcher.new(dependent, name)
add_submatcher(dependent_matcher)
add_submatcher(
AssociationMatchers::DependentMatcher,
dependent,
name,
)
self
end

def order(order)
order_matcher = AssociationMatchers::OrderMatcher.new(order, name)
add_submatcher(order_matcher)
add_submatcher(
AssociationMatchers::OrderMatcher,
order,
name,
)
self
end

def counter_cache(counter_cache = true)
counter_cache_matcher = AssociationMatchers::CounterCacheMatcher.new(counter_cache, name)
add_submatcher(counter_cache_matcher)
add_submatcher(
AssociationMatchers::CounterCacheMatcher,
counter_cache,
name,
)
self
end

def inverse_of(inverse_of)
inverse_of_matcher =
AssociationMatchers::InverseOfMatcher.new(inverse_of, name)
add_submatcher(inverse_of_matcher)
add_submatcher(
AssociationMatchers::InverseOfMatcher,
inverse_of,
name,
)
self
end

def source(source)
source_matcher = AssociationMatchers::SourceMatcher.new(source, name)
add_submatcher(source_matcher)
add_submatcher(
AssociationMatchers::SourceMatcher,
source,
name,
)
self
end

Expand Down Expand Up @@ -955,6 +1041,26 @@ def with_primary_key(primary_key)
self
end

def required(required = true)
remove_submatcher(AssociationMatchers::OptionalMatcher)
add_submatcher(
AssociationMatchers::RequiredMatcher,
name,
required,
)
self
end

def optional(optional = true)
remove_submatcher(AssociationMatchers::RequiredMatcher)
add_submatcher(
AssociationMatchers::OptionalMatcher,
name,
optional,
)
self
end

def validate(validate = true)
@options[:validate] = validate
self
Expand Down Expand Up @@ -1016,8 +1122,15 @@ def reflector
@reflector ||= AssociationMatchers::ModelReflector.new(subject, name)
end

def add_submatcher(matcher)
@submatchers << matcher
def add_submatcher(matcher_class, *args)
remove_submatcher(matcher_class)
submatchers << matcher_class.new(*args)
end

def remove_submatcher(matcher_class)
submatchers.delete_if do |submatcher|
submatcher.is_a?(matcher_class)
end
end

def macro_description
Expand All @@ -1039,7 +1152,7 @@ def expectation

def missing_options
missing_options = [missing, missing_options_for_failing_submatchers]
missing_options.flatten.compact.join(', ')
missing_options.flatten.select(&:present?).join(', ')
end

def failing_submatchers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Shoulda
module Matchers
module ActiveRecord
module AssociationMatchers
# @private
class OptionalMatcher
attr_reader :missing_option

def initialize(attribute_name, optional)
@attribute_name = attribute_name
@missing_option = ''
@submatcher = submatcher_class_for(optional).new(nil).
for(attribute_name).
with_message(:required)
end

def description
'required: true'
end

def matches?(subject)
if submatcher.matches?(subject)
true
else
@missing_option =
'the association should have been defined ' +
'with `optional: true`, but was not'
false
end
end

private

attr_reader :subject, :submatcher

def submatcher_class_for(optional)
if optional
ActiveModel::AllowValueMatcher
else
ActiveModel::DisallowValueMatcher
end
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
module Shoulda
module Matchers
module ActiveRecord
module AssociationMatchers
# @private
class RequiredMatcher
attr_reader :missing_option

def initialize(attribute_name, required)
@missing_option = ''
@submatcher = submatcher_class_for(required).new(nil).
for(attribute_name).
with_message(validation_message_key)
end

def description
'required: true'
end

def matches?(subject)
if submatcher.matches?(subject)
true
else
@missing_option =
'the association should have been defined ' +
'with `required: true`, but was not'
false
end
end

private

attr_reader :subject, :submatcher

def submatcher_class_for(required)
if required
ActiveModel::DisallowValueMatcher
else
ActiveModel::AllowValueMatcher
end
end

def validation_message_key
RailsShim.validation_message_key_for_association_required_option
end
end
end
end
end
end
18 changes: 15 additions & 3 deletions lib/shoulda/matchers/rails_shim.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ def action_pack_version
Gem::Version.new('0')
end

def active_record_major_version
::ActiveRecord::VERSION::MAJOR
def active_record_gte_5?
Gem::Requirement.new('>= 5').satisfied_by?(active_record_version)
end

def active_record_version
Gem::Version.new(::ActiveRecord::VERSION::STRING)
rescue NameError
Gem::Version.new('0')
end
Expand Down Expand Up @@ -92,7 +96,7 @@ def type_cast_default_for(model, column)
end

def tables_and_views(connection)
if active_record_major_version >= 5
if active_record_gte_5?
connection.data_sources
else
connection.tables
Expand All @@ -107,6 +111,14 @@ def verb_for_update
end
end

def validation_message_key_for_association_required_option
if active_record_gte_5?
:required
else
:blank
end
end

private

def simply_generate_validation_message(
Expand Down
4 changes: 4 additions & 0 deletions spec/support/unit/helpers/active_record_versions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,9 @@ def active_record_supports_more_dependent_options?
def active_record_uniqueness_supports_array_columns?
active_record_version < 5
end

def active_record_supports_required_for_associations?
active_record_version >= 5
end
end
end
Loading

0 comments on commit 3af3d9f

Please sign in to comment.