From b514e2767fd72c61cd0f082abd320897cfbe0099 Mon Sep 17 00:00:00 2001 From: Blythe Dunham Date: Thu, 24 Dec 2009 13:25:09 -0800 Subject: [PATCH 1/5] apply patch --- activerecord/lib/active_record.rb | 1 + activerecord/lib/active_record/base.rb | 4 ++ .../connection_adapters/abstract_adapter.rb | 7 +++ .../connection_adapters/mysql_adapter.rb | 12 ++++++ .../connection_adapters/postgresql_adapter.rb | 7 +++ .../connection_adapters/sqlite_adapter.rb | 7 +++ activerecord/lib/active_record/locale/en.yml | 2 + .../lib/active_record/unique_constraints.rb | 24 ++++++++++++ activerecord/test/cases/adapter_test.rb | 35 +++++++++++++++++ activerecord/test/cases/unique_constraints_test.rb | 40 ++++++++++++++++++++ activerecord/test/models/unique_item.rb | 2 + activerecord/test/schema/schema.rb | 15 +++++++ 12 files changed, 156 insertions(+), 0 deletions(-) create mode 100644 activerecord/lib/active_record/unique_constraints.rb create mode 100644 activerecord/test/cases/unique_constraints_test.rb create mode 100644 activerecord/test/models/unique_item.rb diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 196b87c..057773a 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -69,6 +69,7 @@ module ActiveRecord autoload :Timestamp autoload :Transactions autoload :Types + autoload :UniqueConstraints autoload :Validations end diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 321bba4..da52ec1 100755 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -3117,6 +3117,10 @@ module ActiveRecord #:nodoc: include Aggregations, Transactions, Reflection, Batches, Calculations, Serialization + # UniqueConstraints needs to be included after Transactions, because we need #save_with_unique_constraints + # to rescue after the transaction has been resolved for postgres. + include UniqueConstraints + end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 8fae26b..832b962 100755 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -198,6 +198,13 @@ module ActiveRecord end end + # given an ActiveRecord::RecordNotUnique exception and a table name this returns the index whose + # unique constraint was violated, or nil if it can't determine the index + def index_for_record_not_unique(not_unique_exception, table_name) #:nodoc: + # override in derived class + nil + end + protected def log(sql, name, &block) ActiveSupport::Notifications.instrument(:sql, :sql => sql, :name => name, &block) diff --git a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb index fa28bc6..d83e3d0 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb @@ -576,6 +576,18 @@ module ActiveRecord where_sql end + def index_for_record_not_unique(exception, table_name) #:nodoc: + case exception.message + when /Duplicate entry.*for key (\d+)/ + index_position = $1.to_i + # minus two b/c mysql message is one-based + rails excludes primary key index from indexes list + indexes(table_name)[index_position - 2] + when /Duplicate entry.*for key '(\w+)'/ + index_name = $1 + indexes(table_name).detect { |i| i.name == index_name } + end + end + protected def translate_exception(exception, message) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 1d52c5e..70bbbd8 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -929,6 +929,13 @@ module ActiveRecord sql << order_columns * ', ' end + def index_for_record_not_unique(exception, table_name) #:nodoc: + if match = exception.message.match(/unique constraint "(\w+)"/) + index_name = match[1] + indexes(table_name).detect { |i| i.name == index_name } + end + end + protected # Returns the version of the connected PostgreSQL version. def postgresql_version diff --git a/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb index c9c2892..72dc049 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb @@ -294,6 +294,13 @@ module ActiveRecord "VALUES(NULL)" end + def index_for_record_not_unique(exception, table_name) #:nodoc: + if match = exception.message.match(/column(?:s)? (.*) (?:is|are) not unique/) + index_columns = match[1].split(', ') + indexes(table_name).detect { |i| i.columns == index_columns } + end + end + protected def select(sql, name = nil) #:nodoc: execute(sql, name).map do |row| diff --git a/activerecord/lib/active_record/locale/en.yml b/activerecord/lib/active_record/locale/en.yml index 092f5f0..8144735 100644 --- a/activerecord/lib/active_record/locale/en.yml +++ b/activerecord/lib/active_record/locale/en.yml @@ -15,6 +15,8 @@ en: too_short: "is too short (minimum is {{count}} characters)" wrong_length: "is the wrong length (should be {{count}} characters)" taken: "has already been taken" + taken_multiple: "has already been taken for {{context}}" + taken_generic: "Unique requirement not met" not_a_number: "is not a number" greater_than: "must be greater than {{count}}" greater_than_or_equal_to: "must be greater than or equal to {{count}}" diff --git a/activerecord/lib/active_record/unique_constraints.rb b/activerecord/lib/active_record/unique_constraints.rb new file mode 100644 index 0000000..7a17b6a --- /dev/null +++ b/activerecord/lib/active_record/unique_constraints.rb @@ -0,0 +1,24 @@ +module ActiveRecord + # See ActiveRecord::Transactions::ClassMethods for documentation. + module UniqueConstraints + def self.included(base) + base.class_eval do + alias_method_chain :save, :unique_constraints + end + end + + def save_with_unique_constraints(perform_validation = true) #:nodoc: + save_without_unique_constraints(perform_validation) + rescue ActiveRecord::RecordNotUnique => e + index = connection.index_for_record_not_unique(e, self.class.table_name) + if !index + errors[:base] << I18n.translate(:'activerecord.errors.messages.taken_generic') + elsif index.columns.size == 1 + errors.add(index.columns.first, :taken, :value => attributes[index.columns.first]) + else + errors.add(index.columns.first, :taken_multiple, :context => index.columns.slice(1..-1).join('/'), :value => attributes[index.columns.first]) + end + false + end + end +end \ No newline at end of file diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index c59be26..4c8f58b 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -142,4 +142,39 @@ class AdapterTest < ActiveRecord::TestCase end end end + + # tests for db-enforced unique constraints + unique_scenarios = { + :single => {:sql => "INSERT INTO unique_items(uniq) VALUES('abc')", + :expected => ['uniq']}, + :single_named => {:sql => "INSERT INTO unique_items(uniq_named) VALUES('abc')", + :expected => ['uniq_named']}, + :multiple => {:sql => "INSERT INTO unique_items(uniq_1, uniq_2, uniq_3) VALUES('a', 'b', 'c')", + :expected => ['uniq_1', 'uniq_2', 'uniq_3']}, + :multiple_named => {:sql => "INSERT INTO unique_items(uniq_1_named, uniq_2_named, uniq_3_named) VALUES('a', 'b', 'c')", + :expected => ['uniq_1_named', 'uniq_2_named', 'uniq_3_named']} + } + unique_scenarios.each do |name, data| + test "finds index for unique constraint #{name}" do + @connection.execute data[:sql] + exception = get_exception(ActiveRecord::RecordNotUnique) do + @connection.execute data[:sql] + end + # postgres can't query indexes until transaction is rolled back + @connection.rollback_db_transaction if current_adapter?(:PostgreSQLAdapter) + assert_equal data[:expected], @connection.index_for_record_not_unique(exception, 'unique_items').columns + end + end + + protected + + def get_exception(expected_exception) + begin + yield + rescue expected_exception => e + e + else + flunk "Expected #{expected_exception} exception" + end + end end diff --git a/activerecord/test/cases/unique_constraints_test.rb b/activerecord/test/cases/unique_constraints_test.rb new file mode 100644 index 0000000..92ba6d8 --- /dev/null +++ b/activerecord/test/cases/unique_constraints_test.rb @@ -0,0 +1,40 @@ +require "cases/helper" +require 'models/unique_item' + +class UniquenessValidationTest < ActiveRecord::TestCase + + def test_validate_uniqueness_on_create + valid_item = UniqueItem.create!(:uniq => 'abc') + invalid_item = UniqueItem.create(:uniq => 'abc') + assert_equal ["has already been taken"], invalid_item.errors[:uniq] + end + + def test_validate_uniqueness_on_save + item_1 = UniqueItem.create!(:uniq => 'abc') + item_2 = UniqueItem.create!(:uniq => 'xyz') + item_2.uniq = item_1.uniq + item_2.save + assert_equal ["has already been taken"], item_2.errors[:uniq] + end + + def test_validate_uniqueness_on_update + item_1 = UniqueItem.create!(:uniq => 'abc') + item_2 = UniqueItem.create!(:uniq => 'xyz') + item_2.update_attributes(:uniq => item_1.uniq) + assert_equal ["has already been taken"], item_2.errors[:uniq] + end + + def test_validate_uniqueness_multiple + valid_item = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + invalid_item = UniqueItem.create(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + assert_equal ["has already been taken for uniq_2/uniq_3"], invalid_item.errors[:uniq_1] + end + + def test_validate_uniqueness_generic + ActiveRecord::Base.connection.expects(:index_for_record_not_unique).returns(nil) + valid_item = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + invalid_item = UniqueItem.create(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + assert_equal ["Unique requirement not met"], invalid_item.errors[:base] + end + +end \ No newline at end of file diff --git a/activerecord/test/models/unique_item.rb b/activerecord/test/models/unique_item.rb new file mode 100644 index 0000000..3c6f6f0 --- /dev/null +++ b/activerecord/test/models/unique_item.rb @@ -0,0 +1,2 @@ +class UniqueItem < ActiveRecord::Base +end \ No newline at end of file diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 0dd9da4..1aea762 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -532,6 +532,21 @@ ActiveRecord::Schema.define do t.string :title end + create_table :unique_items, :force => true do |t| + t.string :uniq + t.string :uniq_1 + t.string :uniq_2 + t.string :uniq_3 + t.string :uniq_named + t.string :uniq_1_named + t.string :uniq_2_named + t.string :uniq_3_named + end + add_index :unique_items, [:uniq], :unique => true + add_index :unique_items, [:uniq_named], :unique => true, :name => 'unique_named_single_index' + add_index :unique_items, [:uniq_1, :uniq_2, :uniq_3], :unique => true + add_index :unique_items, [:uniq_1_named, :uniq_2_named, :uniq_3_named], :unique => true, :name => 'unique_named_multiple_index' + except 'SQLite' do # fk_test_has_fk should be before fk_test_has_pk create_table :fk_test_has_fk, :force => true do |t| -- 1.6.5 From 72b933e62bcd218d0da4443143437db1be86d88f Mon Sep 17 00:00:00 2001 From: Blythe Dunham Date: Thu, 24 Dec 2009 15:00:58 -0800 Subject: [PATCH 2/5] Add Unique Key violation errors to activerecord object for save! --- .../lib/active_record/unique_constraints.rb | 31 ++++++++++++++++---- activerecord/test/cases/unique_constraints_test.rb | 31 +++++++++++++++++++- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/unique_constraints.rb b/activerecord/lib/active_record/unique_constraints.rb index 7a17b6a..05b3dea 100644 --- a/activerecord/lib/active_record/unique_constraints.rb +++ b/activerecord/lib/active_record/unique_constraints.rb @@ -1,16 +1,36 @@ module ActiveRecord + class RecordInvalidNotUnique < RecordInvalid; end + # See ActiveRecord::Transactions::ClassMethods for documentation. module UniqueConstraints def self.included(base) base.class_eval do - alias_method_chain :save, :unique_constraints + alias_method_chain :save, :unique_constraints + alias_method_chain :save!, :unique_constraints end end - def save_with_unique_constraints(perform_validation = true) #:nodoc: - save_without_unique_constraints(perform_validation) - rescue ActiveRecord::RecordNotUnique => e - index = connection.index_for_record_not_unique(e, self.class.table_name) + def save_with_unique_constraints(*args)#:nodoc: + save_without_unique_constraints(*args) + rescue RecordNotUnique => e + rescue_unique_key_violation(e) + false + end + + def save_with_unique_constraints!(*args)#:nodoc: + save_without_unique_constraints!(*args) + rescue RecordNotUnique => e + rescue_unique_key_violation(e) + raise RecordInvalidNotUnique.new(self) + end + + protected + # Convert the RecordNotUnique exception into an error message + # return true if the violation occurred on column(s) explictly declared to be + # handled + def rescue_unique_key_violation(e)#:nodoc: + index = connection.index_for_record_not_unique(e.original_exception, self.class.table_name) + if !index errors[:base] << I18n.translate(:'activerecord.errors.messages.taken_generic') elsif index.columns.size == 1 @@ -18,7 +38,6 @@ module ActiveRecord else errors.add(index.columns.first, :taken_multiple, :context => index.columns.slice(1..-1).join('/'), :value => attributes[index.columns.first]) end - false end end end \ No newline at end of file diff --git a/activerecord/test/cases/unique_constraints_test.rb b/activerecord/test/cases/unique_constraints_test.rb index 92ba6d8..3416c0e 100644 --- a/activerecord/test/cases/unique_constraints_test.rb +++ b/activerecord/test/cases/unique_constraints_test.rb @@ -9,14 +9,27 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert_equal ["has already been taken"], invalid_item.errors[:uniq] end + def test_validate_uniqueness_on_create! + valid_item = UniqueItem.create!(:uniq => 'abc') + assert_raises_record_invalid{ UniqueItem.create!(:uniq => 'abc') } + end + def test_validate_uniqueness_on_save item_1 = UniqueItem.create!(:uniq => 'abc') item_2 = UniqueItem.create!(:uniq => 'xyz') item_2.uniq = item_1.uniq - item_2.save + assert !item_2.save assert_equal ["has already been taken"], item_2.errors[:uniq] end + def test_validate_uniqueness_on_save! + item_1 = UniqueItem.create!(:uniq => 'abc') + item_2 = UniqueItem.create!(:uniq => 'xyz') + item_2.uniq = item_1.uniq + assert_raises_record_invalid{ item_2.save! } + assert_equal(["has already been taken"], item_2.errors[:uniq]) + end + def test_validate_uniqueness_on_update item_1 = UniqueItem.create!(:uniq => 'abc') item_2 = UniqueItem.create!(:uniq => 'xyz') @@ -24,12 +37,24 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert_equal ["has already been taken"], item_2.errors[:uniq] end + def test_validate_uniqueness_on_update_attributes! + item_1 = UniqueItem.create!(:uniq => 'abc') + item_2 = UniqueItem.create!(:uniq => 'xyz') + assert_raises_record_invalid{ item_2.update_attributes!( :uniq => 'abc') } + assert_equal(["has already been taken"], item_2.errors[:uniq]) + end + def test_validate_uniqueness_multiple valid_item = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') invalid_item = UniqueItem.create(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') assert_equal ["has already been taken for uniq_2/uniq_3"], invalid_item.errors[:uniq_1] end + def test_validate_uniqueness_multiple! + valid_item = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + assert_raises_record_invalid { UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') } + end + def test_validate_uniqueness_generic ActiveRecord::Base.connection.expects(:index_for_record_not_unique).returns(nil) valid_item = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') @@ -37,4 +62,8 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert_equal ["Unique requirement not met"], invalid_item.errors[:base] end + protected + def assert_raises_record_invalid(&block) + assert_raises(ActiveRecord::RecordInvalidNotUnique, &block) + end end \ No newline at end of file -- 1.6.5 From a08841e0c9add6b4feffa7dbf7cc4566f53cc622 Mon Sep 17 00:00:00 2001 From: Blythe Dunham Date: Thu, 24 Dec 2009 15:15:28 -0800 Subject: [PATCH 3/5] rename rescue method --- .../lib/active_record/unique_constraints.rb | 6 +++--- 1 files changed, 3 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/unique_constraints.rb b/activerecord/lib/active_record/unique_constraints.rb index 05b3dea..6ed0c50 100644 --- a/activerecord/lib/active_record/unique_constraints.rb +++ b/activerecord/lib/active_record/unique_constraints.rb @@ -13,14 +13,14 @@ module ActiveRecord def save_with_unique_constraints(*args)#:nodoc: save_without_unique_constraints(*args) rescue RecordNotUnique => e - rescue_unique_key_violation(e) + add_record_not_unique_errors(e) false end def save_with_unique_constraints!(*args)#:nodoc: save_without_unique_constraints!(*args) rescue RecordNotUnique => e - rescue_unique_key_violation(e) + add_record_not_unique_errors(e) raise RecordInvalidNotUnique.new(self) end @@ -28,7 +28,7 @@ module ActiveRecord # Convert the RecordNotUnique exception into an error message # return true if the violation occurred on column(s) explictly declared to be # handled - def rescue_unique_key_violation(e)#:nodoc: + def add_record_not_unique_errors(e)#:nodoc: index = connection.index_for_record_not_unique(e.original_exception, self.class.table_name) if !index -- 1.6.5 From b96feae6eacb28077d41614d6a00917274622209 Mon Sep 17 00:00:00 2001 From: Blythe Dunham Date: Thu, 24 Dec 2009 15:17:30 -0800 Subject: [PATCH 4/5] reraise RecordNotUnique --- .../lib/active_record/unique_constraints.rb | 3 +-- activerecord/test/cases/unique_constraints_test.rb | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/unique_constraints.rb b/activerecord/lib/active_record/unique_constraints.rb index 6ed0c50..d9f2da4 100644 --- a/activerecord/lib/active_record/unique_constraints.rb +++ b/activerecord/lib/active_record/unique_constraints.rb @@ -1,5 +1,4 @@ module ActiveRecord - class RecordInvalidNotUnique < RecordInvalid; end # See ActiveRecord::Transactions::ClassMethods for documentation. module UniqueConstraints @@ -21,7 +20,7 @@ module ActiveRecord save_without_unique_constraints!(*args) rescue RecordNotUnique => e add_record_not_unique_errors(e) - raise RecordInvalidNotUnique.new(self) + raise e end protected diff --git a/activerecord/test/cases/unique_constraints_test.rb b/activerecord/test/cases/unique_constraints_test.rb index 3416c0e..2e27d1a 100644 --- a/activerecord/test/cases/unique_constraints_test.rb +++ b/activerecord/test/cases/unique_constraints_test.rb @@ -64,6 +64,6 @@ class UniquenessValidationTest < ActiveRecord::TestCase protected def assert_raises_record_invalid(&block) - assert_raises(ActiveRecord::RecordInvalidNotUnique, &block) + assert_raises(ActiveRecord::RecordNotUnique, &block) end end \ No newline at end of file -- 1.6.5 From 1777829e922330ca2e0a6b645b95990ebdb9c957 Mon Sep 17 00:00:00 2001 From: Blythe Dunham Date: Thu, 24 Dec 2009 20:31:03 -0800 Subject: [PATCH 5/5] Customization options and validation consistency for validates_unqueness_of using db constraints --- .../lib/active_record/unique_constraints.rb | 106 +++++++++++++++++++- activerecord/test/cases/unique_constraints_test.rb | 88 +++++++++++++++-- activerecord/test/models/unique_item.rb | 17 +++- 3 files changed, 198 insertions(+), 13 deletions(-) diff --git a/activerecord/lib/active_record/unique_constraints.rb b/activerecord/lib/active_record/unique_constraints.rb index d9f2da4..d8bbb96 100644 --- a/activerecord/lib/active_record/unique_constraints.rb +++ b/activerecord/lib/active_record/unique_constraints.rb @@ -1,14 +1,101 @@ module ActiveRecord + class RecordInvalidNotUnique < RecordInvalid; end + VALID_HANDLE_UNIQUE_KEY_OPTIONS = [:scope, :field_name, :message] + # See ActiveRecord::Transactions::ClassMethods for documentation. module UniqueConstraints - def self.included(base) + def self.included(base)#:nodoc: base.class_eval do + extend ClassMethods alias_method_chain :save, :unique_constraints alias_method_chain :save!, :unique_constraints end end + module ClassMethods + def self.extended(base)#:nodoc: + base.class_eval do + class << self + alias_method_chain :validates_uniqueness_of, :unique_key + alias_method :handle_unique_key_violations, :handle_unique_key_violation + end + + class_inheritable_hash :unique_key_validation_options + self.unique_key_validation_options = {} + end + end + + # Specify custom error messages to use when converting database unique key violation errors + # into active record errors. + # For consistency with standard validation exceptions raised by save!, explicitly declared validations + # convert database RecordNotUnique to RecordInvalid derived exceptions. + # + # === Options + # :message - Specifies a custom error message (default is: "has already been taken"). + # :scope - One or more columns by which to limit the scope of the uniqueness constraint. + # + # === Examples + # # The following are equivalent + # class User + # handles_unique_key_violation :name, :message => 'already exists' + # validates_uniqueness_of :user, :message => 'already exists', :lazy => true, + # end + # + # # Convert all violations for all active record classes + # ActiveRecord::Base.handle_unique_key_violations + def handle_unique_key_violation(*attr_names) + options = attr_names.extract_options! + options.symbolize_keys! + options.assert_valid_keys(VALID_HANDLE_UNIQUE_KEY_OPTIONS) + + # when blank, convert all violations + attr_names << :all if attr_names.none? + + attr_names.each do |name| + self.unique_key_validation_options[ name.to_sym ] = if name.to_sym == :all + { :message => options[:message], :field_name => nil, :columns => [] } + else + options.reverse_merge( + :field_name => name.to_s, + :columns => [name.to_s]. + concat( (options[:scope]||[]).collect(&:to_s) ).uniq.sort + ) + end + end + end + + # :lazyReactively convert database unique key violations to active record error messages + # instead of proactively querying the database + # + # === Options + # :lazy option when set to true, the proactive validations are skipped + # Only the :scope and :message options are valid with :lazy => true + # === Examples + # # The following are equivalent + # class User + # handles_unique_key_violation :name, :message => 'already exists' + # validates_uniqueness_of :user, :message => 'already exists', :lazy => true, + # end + # + # # Convert all violations for all active record classes + # ActiveRecord::Base.validates_uniqueness_of :lazy => true + def validates_uniqueness_of_with_unique_key(*attr_names) + configuration = attr_names.extract_options! + configuration.symbolize_keys! + + if configuration.delete(:lazy) + configuration.assert_valid_keys(VALID_HANDLE_UNIQUE_KEY_OPTIONS) + else + validates_uniqueness_of_without_unique_key(*(attr_names + [configuration])) + end + + attr_names << configuration.reject{|k, v| !VALID_HANDLE_UNIQUE_KEY_OPTIONS.include?(k) } + handle_unique_key_violation(*attr_names) + end + + end + def save_with_unique_constraints(*args)#:nodoc: save_without_unique_constraints(*args) rescue RecordNotUnique => e @@ -19,24 +106,37 @@ module ActiveRecord def save_with_unique_constraints!(*args)#:nodoc: save_without_unique_constraints!(*args) rescue RecordNotUnique => e - add_record_not_unique_errors(e) + raise RecordInvalidNotUnique.new( self ) if add_record_not_unique_errors( e ) raise e end protected + # Convert the RecordNotUnique exception into an error message # return true if the violation occurred on column(s) explictly declared to be # handled def add_record_not_unique_errors(e)#:nodoc: index = connection.index_for_record_not_unique(e.original_exception, self.class.table_name) - if !index + # If an index is found, find a the configuration for validates_uniqueness_of + # Or use :all (global) options if specified + record_not_found_options = unique_key_validation_options.values.detect{ |v| + v[:columns] == index.columns.sort || v[:field_name] == index.columns.first + } if index + record_not_found_options ||= unique_key_validation_options[:all] + + if record_not_found_options && record_not_found_options[:message] + column_name = record_not_found_options[:field_name] || index.columns.first + errors.add(column_name,record_not_found_options[:message], :value => column_name) + elsif !index errors[:base] << I18n.translate(:'activerecord.errors.messages.taken_generic') elsif index.columns.size == 1 errors.add(index.columns.first, :taken, :value => attributes[index.columns.first]) else errors.add(index.columns.first, :taken_multiple, :context => index.columns.slice(1..-1).join('/'), :value => attributes[index.columns.first]) end + + !!record_not_found_options end end end \ No newline at end of file diff --git a/activerecord/test/cases/unique_constraints_test.rb b/activerecord/test/cases/unique_constraints_test.rb index 2e27d1a..46c0c17 100644 --- a/activerecord/test/cases/unique_constraints_test.rb +++ b/activerecord/test/cases/unique_constraints_test.rb @@ -1,6 +1,5 @@ require "cases/helper" require 'models/unique_item' - class UniquenessValidationTest < ActiveRecord::TestCase def test_validate_uniqueness_on_create @@ -11,7 +10,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase def test_validate_uniqueness_on_create! valid_item = UniqueItem.create!(:uniq => 'abc') - assert_raises_record_invalid{ UniqueItem.create!(:uniq => 'abc') } + assert_raises_record_not_unique{ UniqueItem.create!(:uniq => 'abc') } end def test_validate_uniqueness_on_save @@ -24,9 +23,8 @@ class UniquenessValidationTest < ActiveRecord::TestCase def test_validate_uniqueness_on_save! item_1 = UniqueItem.create!(:uniq => 'abc') - item_2 = UniqueItem.create!(:uniq => 'xyz') - item_2.uniq = item_1.uniq - assert_raises_record_invalid{ item_2.save! } + item_2 = UniqueItem.new(:uniq => 'abc') + assert_raises_record_not_unique{ item_2.save! } assert_equal(["has already been taken"], item_2.errors[:uniq]) end @@ -37,10 +35,10 @@ class UniquenessValidationTest < ActiveRecord::TestCase assert_equal ["has already been taken"], item_2.errors[:uniq] end - def test_validate_uniqueness_on_update_attributes! + def test_validate_uniqueness_on_update! item_1 = UniqueItem.create!(:uniq => 'abc') item_2 = UniqueItem.create!(:uniq => 'xyz') - assert_raises_record_invalid{ item_2.update_attributes!( :uniq => 'abc') } + assert_raises_record_not_unique{ item_2.update_attributes!( :uniq => 'abc') } assert_equal(["has already been taken"], item_2.errors[:uniq]) end @@ -52,7 +50,73 @@ class UniquenessValidationTest < ActiveRecord::TestCase def test_validate_uniqueness_multiple! valid_item = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') - assert_raises_record_invalid { UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') } + assert_raises_record_not_unique { UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') } + end + + def test_validate_uniqueness_multiple_with_custom_translated_validation_message + UniqueItemWithValidation.validates_uniqueness_of :uniq_1, :message => :invalid, :lazy => true, :scope => %w(uniq_2 uniq_3) + valid_item = UniqueItemWithValidation.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + invalid_item = UniqueItemWithValidation.create(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + assert_equal ["is invalid"], invalid_item.errors[:uniq_1] + end + + def test_validate_uniqueness_raises_record_invalid_with_custom_error_message + UniqueItemWithValidation.validates_uniqueness_of :uniq, :message => 'is really taken.', :lazy => true + item_1 = UniqueItemWithValidation.create!(:uniq => 'abc') + item_2 = UniqueItemWithValidation.create!(:uniq => 'xyz') + item_2.uniq = item_1.uniq + assert_raises_record_invalid{ item_2.save! } + assert_equal ['is really taken.'], item_2.errors[:uniq] + end + + def test_raises_record_not_unique_violation_without_declaring_validates_uniqueness_of(message) + UniqueItemWithValidation.validates_uniqueness_of :uniq, :message => 'is really taken.', :lazy => true + item_1 = UniqueItemWithValidation.create!(:uniq_named => 'abc') + item_2 = UniqueItemWithValidation.create!(:uniq_named => 'xyz') + item_2.uniq_named = item_1.uniq_named + assert_raises_record_not_unique{ item_2.save! } + assert_equal ["has already been taken"], item_2.errors[:uniq_named] + end + + def test_standard_validates_uniqueness_of_save_failure_is_handled + # Standard validation succeeds + UniqueItemWithStandardValidation.expects(:exists?).returns(false) + valid_item = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + invalid_item = UniqueItemWithStandardValidation.new(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + assert_raises_record_invalid{ invalid_item.save! } + assert_equal ["standard validation"], invalid_item.errors[:uniq_1] + end + + def test_standard_validates_uniqueness_of_create_failure_is_handled + # Standard validation succeeds + UniqueItemWithStandardValidation.expects(:exists?).returns(false) + item_1 = UniqueItem.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + assert_raises_record_invalid { + UniqueItemWithStandardValidation.create!(:uniq_1 => 'a', :uniq_2 => 'b', :uniq_3 => 'c') + } + end + + def test_validate_uniqueness_of_all_on_save! + assert(UniqueItemWithAllValidation.unique_key_validation_options.has_key?(:all)) + item_1 = UniqueItemWithAllValidation.create!(:uniq_named => 'abc') + item_2 = UniqueItemWithAllValidation.create!(:uniq_named => 'xyz') + item_2.uniq_named = item_1.uniq_named + assert_raises_record_invalid{ item_2.save! } + assert_equal ['all validation messages'], item_2.errors[:uniq_named] + end + + def test_validate_uniqueness_of_invalid_options + assert_raises(ArgumentError) { + UniqueItem.validates_uniqueness_of :uniq, :allow_nil => false, :lazy => true + } + + assert_raises(ArgumentError) { + UniqueItem.handle_unique_key_violations :uniq, :message => 'goo', :if => lambda{ true }, :lazy => true + } + + assert_raises(ArgumentError) { + UniqueItem.handle_unique_key_violation :allow_nil => false, :lazy => true + } end def test_validate_uniqueness_generic @@ -63,7 +127,13 @@ class UniquenessValidationTest < ActiveRecord::TestCase end protected - def assert_raises_record_invalid(&block) + + def assert_raises_record_not_unique(&block) assert_raises(ActiveRecord::RecordNotUnique, &block) end + + def assert_raises_record_invalid(&block) + assert_raises(ActiveRecord::RecordInvalidNotUnique, &block) + end + end \ No newline at end of file diff --git a/activerecord/test/models/unique_item.rb b/activerecord/test/models/unique_item.rb index 3c6f6f0..d34a40a 100644 --- a/activerecord/test/models/unique_item.rb +++ b/activerecord/test/models/unique_item.rb @@ -1,2 +1,17 @@ class UniqueItem < ActiveRecord::Base -end \ No newline at end of file +end + +class UniqueItemWithValidation < ActiveRecord::Base + set_table_name 'unique_items' + validates_uniqueness_of :uniq, :message => :invalid, :lazy => true +end + +class UniqueItemWithAllValidation < ActiveRecord::Base + set_table_name 'unique_items' + validates_uniqueness_of :message => 'all validation messages' +end + +class UniqueItemWithStandardValidation < ActiveRecord::Base + set_table_name 'unique_items' + validates_uniqueness_of :uniq_1, :message => 'standard validation', :scope => [:uniq_2, :uniq_3 ], :allow_nil => true +end -- 1.6.5