Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix uniqueness matcher when scope is a *_type attr #592

Merged
merged 3 commits into from
Oct 9, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

### Bug fixes

* Fix `delegate_method` so that it works again with shoulda-context.
* Fix `delegate_method` so that it works again with shoulda-context. ([#591])

* Fix `validate_uniqueness_of` when used with `scoped_to` so that when one of
the scope attributes is a polymorphic `*_type` attribute and the model has
another validation on the same attribute, the matcher does not fail with an
error. ([#592])

[#591]: https://github.com/thoughtbot/shoulda-matchers/pull/591
[#592]: https://github.com/thoughtbot/shoulda-matchers/pull/592

# 2.7.0

Expand Down
1 change: 1 addition & 0 deletions lib/shoulda/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'shoulda/matchers/error'
require 'shoulda/matchers/matcher_context'
require 'shoulda/matchers/rails_shim'
require 'shoulda/matchers/util'
require 'shoulda/matchers/version'
require 'shoulda/matchers/warn'

Expand Down
1 change: 1 addition & 0 deletions lib/shoulda/matchers/active_model.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'shoulda/matchers/active_model/helpers'
require 'shoulda/matchers/active_model/uniqueness'
require 'shoulda/matchers/active_model/validation_matcher'
require 'shoulda/matchers/active_model/validation_message_finder'
require 'shoulda/matchers/active_model/exception_message_finder'
Expand Down
14 changes: 14 additions & 0 deletions lib/shoulda/matchers/active_model/uniqueness.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Shoulda
module Matchers
module ActiveModel
# @private
module Uniqueness
end
end
end
end

require 'shoulda/matchers/active_model/uniqueness/model'
require 'shoulda/matchers/active_model/uniqueness/namespace'
require 'shoulda/matchers/active_model/uniqueness/test_model_creator'
require 'shoulda/matchers/active_model/uniqueness/test_models'
45 changes: 45 additions & 0 deletions lib/shoulda/matchers/active_model/uniqueness/model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Shoulda
module Matchers
module ActiveModel
module Uniqueness
# @private
class Model
def self.next_unique_copy_of(model_name, namespace)
model = new(model_name, namespace)

while model.already_exists?
model = model.next
end

model
end

def initialize(name, namespace)
@name = name
@namespace = namespace
end

def already_exists?
namespace.has?(name)
end

def next
Model.new(name.next, namespace)
end

def symlink_to(parent)
namespace.set(name, parent.dup)
end

def to_s
[namespace, name].join('::')
end

protected

attr_reader :name, :namespace
end
end
end
end
end
36 changes: 36 additions & 0 deletions lib/shoulda/matchers/active_model/uniqueness/namespace.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module Shoulda
module Matchers
module ActiveModel
module Uniqueness
# @private
class Namespace
def initialize(constant)
@constant = constant
end

def has?(name)
constant.const_defined?(name)
end

def set(name, value)
constant.const_set(name, value)
end

def clear
constant.constants.each do |child_constant|
constant.__send__(:remove_const, child_constant)
end
end

def to_s
constant.to_s
end

protected

attr_reader :constant
end
end
end
end
end
50 changes: 50 additions & 0 deletions lib/shoulda/matchers/active_model/uniqueness/test_model_creator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require 'thread'

module Shoulda
module Matchers
module ActiveModel
module Uniqueness
# @private
class TestModelCreator
def self.create(model_name, namespace)
Mutex.new.synchronize do
new(model_name, namespace).create
end
end

def initialize(model_name, namespace)
@model_name = model_name
@namespace = namespace
end

def create
new_model.tap do |new_model|
new_model.symlink_to(existing_model)
end
end

protected

attr_reader :model_name, :namespace

private

def model_name_without_namespace
model_name.demodulize
end

def new_model
@_new_model ||= Model.next_unique_copy_of(
model_name_without_namespace,
namespace
)
end

def existing_model
@_existing_model ||= model_name.constantize
end
end
end
end
end
end
24 changes: 24 additions & 0 deletions lib/shoulda/matchers/active_model/uniqueness/test_models.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
require 'thread'

module Shoulda
module Matchers
module ActiveModel
module Uniqueness
# @private
module TestModels
def self.create(model_name)
TestModelCreator.create(model_name, root_namespace)
end

def self.remove_all
root_namespace.clear
end

def self.root_namespace
@_root_namespace ||= Namespace.new(self)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,13 @@ def matches?(subject)
@original_subject = subject
@subject = subject.class.new
@expected_message ||= :taken

set_scoped_attributes &&
validate_everything_except_duplicate_nils? &&
validate_after_scope_change? &&
allows_nil?
ensure
Uniqueness::TestModels.remove_all
end

private
Expand Down Expand Up @@ -262,6 +265,7 @@ def create_record_in_database(options = {})
instance.__send__("#{@attribute}=", value)
ensure_secure_password_set(instance)
instance.save(validate: false)
@created_record = instance
end
end

Expand Down Expand Up @@ -305,6 +309,12 @@ def create_record_without_nil
@existing_record = create_record_in_database
end

def model_class?(model_name)
model_name.constantize.ancestors.include?(::ActiveRecord::Base)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

model_name.constantize < ::ActiveRecord::Base

rescue NameError
false
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use safe_constantize and you shouldn't need this.

end

def validate_after_scope_change?
if @options[:scopes].blank?
true
Expand All @@ -322,6 +332,8 @@ def validate_after_scope_change?
key == previous_value
end
available_values.keys.last
elsif scope.to_s =~ /_type$/ && model_class?(previous_value)
Uniqueness::TestModels.create(previous_value).to_s
elsif previous_value.respond_to?(:next)
previous_value.next
elsif previous_value.respond_to?(:to_datetime)
Expand Down
15 changes: 15 additions & 0 deletions lib/shoulda/matchers/util.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module Shoulda
module Matchers
# @private
module Util
def self.deconstantize(path)
if defined?(ActiveSupport::Inflector) &&
ActiveSupport::Inflector.respond_to?(:deconstantize)
ActiveSupport::Inflector.deconstantize(path)
else
path.to_s[0...(path.to_s.rindex('::') || 0)]
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,56 @@ def define_model_without_allow_nil
end
end

context "when testing that a polymorphic *_type column is one of the validation scopes" do
it "sets that column to a meaningful value that works with other validations on the same column" do
user_model = define_model :user
favorite_columns = {
favoriteable_id: { type: :integer, options: { null: false } },
favoriteable_type: { type: :string, options: { null: false } }
}
favorite_model = define_model :favorite, favorite_columns do
attr_accessible :favoriteable
belongs_to :favoriteable, polymorphic: true
validates :favoriteable, presence: true
validates :favoriteable_id, uniqueness: { scope: :favoriteable_type }
end

user = user_model.create!
favorite_model.create!(favoriteable: user)
new_favorite = favorite_model.new

expect(new_favorite).
to validate_uniqueness_of(:favoriteable_id).
scoped_to(:favoriteable_type)
end

context "if the model the *_type column refers to is namespaced, and shares the last part of its name with an existing model" do
it "still works" do
define_class 'User'
define_module 'Models'
user_model = define_model 'Models::User'
favorite_columns = {
favoriteable_id: { type: :integer, options: { null: false } },
favoriteable_type: { type: :string, options: { null: false } }
}
favorite_model = define_model 'Models::Favorite', favorite_columns do
attr_accessible :favoriteable
belongs_to :favoriteable, polymorphic: true
validates :favoriteable, presence: true
validates :favoriteable_id, uniqueness: { scope: :favoriteable_type }
end

user = user_model.create!
favorite_model.create!(favoriteable: user)
new_favorite = favorite_model.new

expect(new_favorite).
to validate_uniqueness_of(:favoriteable_id).
scoped_to(:favoriteable_type)
end
end
end

def case_sensitive_validation_with_existing_value(attr_type)
model = define_model(:example, attr: attr_type) do
attr_accessible :attr
Expand Down
62 changes: 46 additions & 16 deletions spec/support/class_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,61 @@ def self.included(example_group)
end
end

def define_class(class_name, base = Object, &block)
class_name = class_name.to_s.camelize
def self.parse_constant_name(name)
namespace = Shoulda::Matchers::Util.deconstantize(name)
qualified_namespace = (namespace.presence || 'Object').constantize
name_without_namespace = name.to_s.demodulize
[qualified_namespace, name_without_namespace]
end

def define_module(module_name, &block)
module_name = module_name.to_s.camelize

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

eval <<-RUBY
module #{namespace}::#{name_without_namespace}
end
RUBY

if Object.const_defined?(class_name)
Object.__send__(:remove_const, class_name)
namespace.const_get(name_without_namespace).tap do |constant|
constant.unloadable

if block
constant.module_eval(&block)
end
end
end

def define_class(class_name, parent_class = Object, &block)
class_name = class_name.to_s.camelize

namespace, name_without_namespace =
ClassBuilder.parse_constant_name(class_name)

# FIXME: ActionMailer 3.2 calls `name.underscore` immediately upon
# subclassing. Class.new.name == nil. So, Class.new(ActionMailer::Base)
# errors out since it's trying to do `nil.underscore`. This is very ugly but
# allows us to test against ActionMailer 3.2.x.
eval <<-A_REAL_CLASS_FOR_ACTION_MAILER_3_2
class ::#{class_name} < #{base}
if namespace.const_defined?(name_without_namespace, false)
namespace.__send__(:remove_const, name_without_namespace)
end
A_REAL_CLASS_FOR_ACTION_MAILER_3_2

Object.const_get(class_name).tap do |constant_class|
constant_class.unloadable
eval <<-RUBY
class #{namespace}::#{name_without_namespace} < #{parent_class}
end
RUBY

namespace.const_get(name_without_namespace).tap do |constant|
constant.unloadable

if block_given?
constant_class.class_eval(&block)
constant.class_eval(&block)
end

if constant_class.respond_to?(:reset_column_information)
constant_class.reset_column_information
if constant.respond_to?(:reset_column_information)
constant.reset_column_information
end
end
end
Expand Down
Loading