Skip to content
Browse files

Add Sequel::ConstraintViolation exception class and subclasses for ea…

…sier exception handling

Previously, Sequel raised all database errors as
Sequel::DatabaseErrors, except disconnect errors.  This adds support
for adapters to use regexps to scan exception messages and raise them
as more specific types.  5 new exception classes have been added:

* Sequel::ConstraintViolation (generic superclass)
* Sequel::UniqueConstraintViolation
* Sequel::ForeignKeyConstraintViolation
* Sequel::CheckConstraintViolation
* Sequel::NotNullConstraintViolation

The framework adding support for this is generic enough to handle
other exception types, including adapter/database specific types.

Update RDoc for some exception classes and minor refactoring to
database_spec.rb while here.
  • Loading branch information...
1 parent 715b709 commit 3b74892e922306539fe29a37aaf895b9e102f402 @jeremyevans committed Jan 24, 2013
View
2 CHANGELOG
@@ -1,5 +1,7 @@
=== HEAD
+* Add Sequel::ConstraintViolation exception class and subclasses for easier exception handling (jeremyevans)
+
* Fix use of identity_map plugin with many_to_many associations with right composite keys (chanks) (#603)
* Increase virtual row performance by using a shared VirtualRow instance (jeremyevans)
View
10 lib/sequel/adapters/jdbc/derby.rb
@@ -104,6 +104,16 @@ def create_table_prefix_sql(name, options)
end
end
+ DATABASE_ERROR_REGEXPS = {
+ /The statement was aborted because it would have caused a duplicate key value in a unique or primary key constraint or unique index/ => UniqueConstraintViolation,
+ /violation of foreign key constraint/ => ForeignKeyConstraintViolation,
+ /The check constraint .+ was violated/ => CheckConstraintViolation,
+ /cannot accept a NULL value/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# Use IDENTITY_VAL_LOCAL() to get the last inserted id.
def last_insert_id(conn, opts={})
statement(conn) do |stmt|
View
10 lib/sequel/adapters/jdbc/h2.rb
@@ -96,6 +96,16 @@ def connection_pool_default_options
uri == 'jdbc:h2:mem:' ? o.merge(:max_connections=>1) : o
end
+ DATABASE_ERROR_REGEXPS = {
+ /Unique index or primary key violation/ => UniqueConstraintViolation,
+ /Referential integrity constraint violation/ => ForeignKeyConstraintViolation,
+ /Check constraint violation/ => CheckConstraintViolation,
+ /NULL not allowed for column/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# Use IDENTITY() to get the last inserted id.
def last_insert_id(conn, opts={})
statement(conn) do |stmt|
View
10 lib/sequel/adapters/jdbc/hsqldb.rb
@@ -52,6 +52,16 @@ def create_table_as_sql(name, sql, options)
"#{create_table_prefix_sql(name, options)} AS (#{sql}) WITH DATA"
end
+ DATABASE_ERROR_REGEXPS = {
+ /integrity constraint violation: unique constraint or index violation/ => UniqueConstraintViolation,
+ /integrity constraint violation: foreign key/ => ForeignKeyConstraintViolation,
+ /integrity constraint violation: check constraint/ => CheckConstraintViolation,
+ /integrity constraint violation: NOT NULL check constraint/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# Use IDENTITY() to get the last inserted id.
def last_insert_id(conn, opts={})
statement(conn) do |stmt|
View
5 lib/sequel/adapters/jdbc/sqlite.rb
@@ -30,6 +30,11 @@ def indexes(table, opts={})
private
+ DATABASE_ERROR_REGEXPS = {/Abort due to constraint violation/ => ConstraintViolation}.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# Use last_insert_rowid() to get the last inserted id.
def last_insert_id(conn, opts={})
statement(conn) do |stmt|
View
10 lib/sequel/adapters/shared/access.rb
@@ -43,6 +43,16 @@ def create_table_as(name, ds, options)
run(ds.into(name).sql)
end
+ DATABASE_ERROR_REGEXPS = {
+ /The changes you requested to the table were not successful because they would create duplicate values in the index, primary key, or relationship/ => UniqueConstraintViolation,
+ /You cannot add or change a record because a related record is required|The record cannot be deleted or changed because table/ => ForeignKeyConstraintViolation,
+ /One or more values are prohibited by the validation rule/ => CheckConstraintViolation,
+ /You must enter a value in the .+ field|cannot contain a Null value because the Required property for this field is set to True/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# The SQL to drop an index for the table.
def drop_index_sql(table, op)
"DROP INDEX #{quote_identifier(op[:name] || default_index_name(table, op[:columns]))} ON #{quote_schema_table(table)}"
View
9 lib/sequel/adapters/shared/cubrid.rb
@@ -126,6 +126,15 @@ def connection_execute_method
:query
end
+ DATABASE_ERROR_REGEXPS = {
+ /Operation would have caused one or more unique constraint violations/ => UniqueConstraintViolation,
+ /The constraint of the foreign key .+ is invalid|Update\/Delete operations are restricted by the foreign key/ => ForeignKeyConstraintViolation,
+ /cannot be made NULL/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# CUBRID is case insensitive, so don't modify identifiers
def identifier_input_method_default
nil
View
10 lib/sequel/adapters/shared/db2.rb
@@ -154,6 +154,16 @@ def create_table_prefix_sql(name, options)
end
end
+ DATABASE_ERROR_REGEXPS = {
+ /DB2 SQL Error: SQLCODE=-803, SQLSTATE=23505|One or more values in the INSERT statement, UPDATE statement, or foreign key update caused by a DELETE statement are not valid because the primary key, unique constraint or unique index/ => UniqueConstraintViolation,
+ /DB2 SQL Error: (SQLCODE=-530, SQLSTATE=23503|SQLCODE=-532, SQLSTATE=23504)|The insert or update value of the FOREIGN KEY .+ is not equal to any value of the parent key of the parent table|A parent row cannot be deleted because the relationship .+ restricts the deletion/ => ForeignKeyConstraintViolation,
+ /DB2 SQL Error: SQLCODE=-545, SQLSTATE=23513|The requested operation is not allowed because a row does not satisfy the check constraint/ => CheckConstraintViolation,
+ /DB2 SQL Error: SQLCODE=-407, SQLSTATE=23502|Assignment of a NULL value to a NOT NULL column/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# DB2 has issues with quoted identifiers, so
# turn off database quoting by default.
def quote_identifiers_default
View
10 lib/sequel/adapters/shared/mssql.rb
@@ -238,6 +238,16 @@ def create_table_as(name, ds, options)
run(ds.into(name).sql)
end
+ DATABASE_ERROR_REGEXPS = {
+ /Violation of UNIQUE KEY constraint/ => UniqueConstraintViolation,
+ /conflicted with the (FOREIGN KEY.*|REFERENCE) constraint/ => ForeignKeyConstraintViolation,
+ /conflicted with the CHECK constraint/ => CheckConstraintViolation,
+ /column does not allow nulls/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# The name of the constraint for setting the default value on the table and column.
# The SQL used to select default constraints utilizes MSSQL catalog views which were introduced in 2005.
# This method intentionally does not support MSSQL 2000.
View
9 lib/sequel/adapters/shared/mysql.rb
@@ -350,6 +350,15 @@ def create_table_sql(name, generator, options = {})
"#{super}#{" ENGINE=#{engine}" if engine}#{" DEFAULT CHARSET=#{charset}" if charset}#{" DEFAULT COLLATE=#{collate}" if collate}"
end
+ DATABASE_ERROR_REGEXPS = {
+ /Duplicate entry .+ for key/ => UniqueConstraintViolation,
+ /foreign key constraint fails/ => ForeignKeyConstraintViolation,
+ /cannot be null/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# Backbone of the tables and views support using SHOW FULL TABLES.
def full_tables(type, opts)
m = output_identifier_meth
View
10 lib/sequel/adapters/shared/oracle.rb
@@ -136,6 +136,16 @@ def create_trigger_sql(table, name, definition, opts={})
sql
end
+ DATABASE_ERROR_REGEXPS = {
+ /unique constraint .+ violated/ => UniqueConstraintViolation,
+ /integrity constraint .+ violated/ => ForeignKeyConstraintViolation,
+ /check constraint .+ violated/ => CheckConstraintViolation,
+ /cannot insert NULL into|cannot update .+ to NULL/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
def default_sequence_name(table, column)
"seq_#{table}_#{column}"
end
View
10 lib/sequel/adapters/shared/postgres.rb
@@ -627,6 +627,16 @@ def constraint_definition_sql(constraint)
end
end
+ DATABASE_ERROR_REGEXPS = {
+ /duplicate key value violates unique constraint/ => UniqueConstraintViolation,
+ /violates foreign key constraint/ => ForeignKeyConstraintViolation,
+ /violates check constraint/ => CheckConstraintViolation,
+ /violates not-null constraint/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# SQL for doing fast table insert from stdin.
def copy_into_sql(table, opts)
sql = "COPY #{literal(table)}"
View
10 lib/sequel/adapters/shared/sqlite.rb
@@ -312,6 +312,16 @@ def connection_pragmas
ps
end
+ DATABASE_ERROR_REGEXPS = {
+ /is not unique\z/ => UniqueConstraintViolation,
+ /foreign key constraint failed\z/ => ForeignKeyConstraintViolation,
+ /\A(SQLITE ERROR 19 \(CONSTRAINT\) : )?constraint failed\z/ => CheckConstraintViolation,
+ /may not be NULL\z/ => NotNullConstraintViolation,
+ }.freeze
+ def database_error_regexps
+ DATABASE_ERROR_REGEXPS
+ end
+
# The array of column schema hashes for the current columns in the table
def defined_columns_for(table)
cols = parse_pragma(table, {})
View
38 lib/sequel/database/misc.rb
@@ -14,6 +14,10 @@ class Database
# instances.
DEFAULT_STRING_COLUMN_SIZE = 255
+ # Empty exception regexp to class map, used by default if Sequel doesn't
+ # have specific support for the database in use.
+ DEFAULT_DATABASE_ERROR_REGEXPS = {}.freeze
+
# Register an extension callback for Database objects. ext should be the
# extension name symbol, and mod should either be a Module that the
# database is extended with, or a callable object called with the database
@@ -316,22 +320,48 @@ def blank_object?(obj)
obj.respond_to?(:empty?) ? obj.empty? : false
end
end
-
+
# Which transaction errors to translate, blank by default.
def database_error_classes
[]
end
+ # A hash with regexp keys and exception class values, used
+ # to match against underlying driver exception messages in
+ # order to raise a more specific Sequel::Error subclass.
+ def database_error_regexps
+ DEFAULT_DATABASE_ERROR_REGEXPS
+ end
+
+ # Return the Sequel::DatabaseError subclass to wrap the given
+ # exception in.
+ def database_error_class(exception, opts)
+ database_specific_error_class(exception, opts) || DatabaseError
+ end
+
+ # Return a specific Sequel::DatabaseError exception class if
+ # one is appropriate for the underlying exception,
+ # or nil if there is no specific exception class.
+ def database_specific_error_class(exception, opts)
+ return DatabaseDisconnectError if disconnect_error?(exception, opts)
+
+ database_error_regexps.each do |regexp, klass|
+ return klass if exception.message =~ regexp
+ end
+
+ nil
+ end
+
# Return true if exception represents a disconnect error, false otherwise.
def disconnect_error?(exception, opts)
opts[:disconnect]
end
- # Convert the given exception to a DatabaseError, keeping message
- # and traceback.
+ # Convert the given exception to an appropriate Sequel::DatabaseError
+ # subclass, keeping message and traceback.
def raise_error(exception, opts={})
if !opts[:classes] || Array(opts[:classes]).any?{|c| exception.is_a?(c)}
- raise Sequel.convert_exception_class(exception, disconnect_error?(exception, opts) ? DatabaseDisconnectError : DatabaseError)
+ raise Sequel.convert_exception_class(exception, database_error_class(exception, opts))
else
raise exception
end
View
29 lib/sequel/exceptions.rb
@@ -19,32 +19,47 @@ class DatabaseError < Error; end
# connection parameters it was given.
class DatabaseConnectionError < DatabaseError; end
- # Error that should be raised by adapters when they determine that the connection
+ # Error raised by adapters when they determine that the connection
# to the database has been lost. Instructs the connection pool code to
# remove that connection from the pool so that other connections can be acquired
# automatically.
class DatabaseDisconnectError < DatabaseError; end
- # Raised on an invalid operation, such as trying to update or delete
+ # Generic error raised when Sequel determines a database constraint has been violated.
+ class ConstraintViolation < DatabaseError; end
+
+ # Error raised when Sequel determines a database check constraint has been violated.
+ class CheckConstraintViolation < ConstraintViolation; end
+
+ # Error raised when Sequel determines a database foreign key constraint has been violated.
+ class ForeignKeyConstraintViolation < ConstraintViolation; end
+
+ # Error raised when Sequel determines a database NOT NULL constraint has been violated.
+ class NotNullConstraintViolation < ConstraintViolation; end
+
+ # Error raised when Sequel determines a database unique constraint has been violated.
+ class UniqueConstraintViolation < ConstraintViolation; end
+
+ # Error raised on an invalid operation, such as trying to update or delete
# a joined or grouped dataset.
class InvalidOperation < Error; end
- # Raised when attempting an invalid type conversion.
+ # Error raised when attempting an invalid type conversion.
class InvalidValue < Error ; end
- # Raised when the adapter adapter hasn't implemented a method such as +tables+:
+ # Error raised when the adapter adapter hasn't implemented a method such as +tables+:
class NotImplemented < Error; end
- # Raised when the connection pool cannot acquire a database connection
+ # Error raised when the connection pool cannot acquire a database connection
# before the timeout.
class PoolTimeout < Error ; end
# Exception that you should raise to signal a rollback of the current transaction.
# The transaction block will catch this exception, rollback the current transaction,
- # and won't reraise it.
+ # and won't reraise it (unless a reraise is requested).
class Rollback < Error ; end
- # Exception that occurs when unbinding a dataset that has multiple different values
+ # Error raised when unbinding a dataset that has multiple different values
# for a given variable.
class UnbindDuplicate < Error; end
View
8 spec/core/database_spec.rb
@@ -1669,6 +1669,14 @@ def @db.dc; @dc end
specify "should convert the exception to a DatabaseDisconnectError if opts[:disconnect] is true" do
proc{@db.send(:raise_error, Interrupt.new(''), :disconnect=>true)}.should raise_error(Sequel::DatabaseDisconnectError)
end
+
+ specify "should convert the exception to an appropriate error if exception message matches regexp" do
+ def @db.database_error_regexps
+ {/foo/ => Sequel::DatabaseDisconnectError, /bar/ => Sequel::ConstraintViolation}
+ end
+ proc{@db.send(:raise_error, Interrupt.new('foo'))}.should raise_error(Sequel::DatabaseDisconnectError)
+ proc{@db.send(:raise_error, Interrupt.new('bar'))}.should raise_error(Sequel::ConstraintViolation)
+ end
end
describe "Database#typecast_value" do
View
88 spec/integration/database_test.rb
@@ -1,28 +1,76 @@
require File.join(File.dirname(File.expand_path(__FILE__)), 'spec_helper.rb')
describe Sequel::Database do
+ before do
+ @db = INTEGRATION_DB
+ end
+
specify "should provide disconnect functionality" do
- INTEGRATION_DB.disconnect
- INTEGRATION_DB.pool.size.should == 0
- INTEGRATION_DB.test_connection
- INTEGRATION_DB.pool.size.should == 1
+ @db.disconnect
+ @db.pool.size.should == 0
+ @db.test_connection
+ @db.pool.size.should == 1
end
specify "should provide disconnect functionality after preparing a statement" do
- INTEGRATION_DB.create_table!(:items){Integer :i}
- INTEGRATION_DB[:items].prepare(:first, :a).call
- INTEGRATION_DB.disconnect
- INTEGRATION_DB.pool.size.should == 0
- INTEGRATION_DB.drop_table?(:items)
+ @db.create_table!(:items){Integer :i}
+ @db[:items].prepare(:first, :a).call
+ @db.disconnect
+ @db.pool.size.should == 0
+ @db.drop_table?(:items)
end
specify "should raise Sequel::DatabaseError on invalid SQL" do
- proc{INTEGRATION_DB << "SELECT"}.should raise_error(Sequel::DatabaseError)
+ proc{@db << "SELECT"}.should raise_error(Sequel::DatabaseError)
+ end
+
+ describe "constraint violations" do
+ before do
+ @db.drop_table?(:test2, :test)
+ end
+ after do
+ @db.drop_table?(:test2, :test)
+ end
+
+ cspecify "should raise Sequel::UniqueConstraintViolation when a unique constraint is violated", [:jdbc, :sqlite], [:db2] do
+ @db.create_table!(:test){String :a, :unique=>true, :null=>false}
+ @db[:test].insert('1')
+ proc{@db[:test].insert('1')}.should raise_error(Sequel::UniqueConstraintViolation)
+ @db[:test].insert('2')
+ proc{@db[:test].update(:a=>'1')}.should raise_error(Sequel::UniqueConstraintViolation)
+ end
+
+ cspecify "should raise Sequel::CheckConstraintViolation when a check constraint is violated", :mysql, [:jdbc, :sqlite], [:db2] do
+ @db.create_table!(:test){String :a; check Sequel.~(:a=>'1')}
+ proc{@db[:test].insert('1')}.should raise_error(Sequel::CheckConstraintViolation)
+ @db[:test].insert('2')
+ proc{@db[:test].insert('1')}.should raise_error(Sequel::CheckConstraintViolation)
+ end
+
+ cspecify "should raise Sequel::ForeignKeyConstraintViolation when a foreign key constraint is violated", [:jdbc, :sqlite], [:db2] do
+ @db.create_table!(:test, :engine=>:InnoDB){primary_key :id}
+ @db.create_table!(:test2, :engine=>:InnoDB){foreign_key :tid, :test}
+ proc{@db[:test2].insert(:tid=>1)}.should raise_error(Sequel::ForeignKeyConstraintViolation)
+ @db[:test].insert
+ @db[:test2].insert(:tid=>1)
+ proc{@db[:test2].where(:tid=>1).update(:tid=>3)}.should raise_error(Sequel::ForeignKeyConstraintViolation)
+ proc{@db[:test].where(:id=>1).delete}.should raise_error(Sequel::ForeignKeyConstraintViolation)
+ end
+
+ cspecify "should raise Sequel::NotNullConstraintViolation when a not null constraint is violated", [:jdbc, :sqlite], [:db2] do
+ @db.create_table!(:test){Integer :a, :null=>false}
+ proc{@db[:test].insert(:a=>nil)}.should raise_error(Sequel::NotNullConstraintViolation)
+ unless @db.database_type == :mysql
+ # Broken mysql silently changes NULL here to 0, and doesn't raise an exception.
+ @db[:test].insert(2)
+ proc{@db[:test].update(:a=>nil)}.should raise_error(Sequel::NotNullConstraintViolation)
+ end
+ end
end
specify "should store underlying wrapped exception in Sequel::DatabaseError" do
begin
- INTEGRATION_DB << "SELECT"
+ @db << "SELECT"
rescue Sequel::DatabaseError=>e
if defined?(Java::JavaLang::Exception)
(e.wrapped_exception.is_a?(Exception) || e.wrapped_exception.is_a?(Java::JavaLang::Exception)).should be_true
@@ -33,20 +81,20 @@
end
specify "should not have the connection pool swallow non-StandardError based exceptions" do
- proc{INTEGRATION_DB.pool.hold{raise Interrupt, "test"}}.should raise_error(Interrupt)
+ proc{@db.pool.hold{raise Interrupt, "test"}}.should raise_error(Interrupt)
end
specify "should be able to disconnect connections more than once without exceptions" do
- conn = INTEGRATION_DB.synchronize{|c| c}
- INTEGRATION_DB.disconnect
- INTEGRATION_DB.disconnect_connection(conn)
- INTEGRATION_DB.disconnect_connection(conn)
+ conn = @db.synchronize{|c| c}
+ @db.disconnect
+ @db.disconnect_connection(conn)
+ @db.disconnect_connection(conn)
end
cspecify "should provide ability to check connections for validity", [:do, :postgres] do
- conn = INTEGRATION_DB.synchronize{|c| c}
- INTEGRATION_DB.valid_connection?(conn).should be_true
- INTEGRATION_DB.disconnect
- INTEGRATION_DB.valid_connection?(conn).should be_false
+ conn = @db.synchronize{|c| c}
+ @db.valid_connection?(conn).should be_true
+ @db.disconnect
+ @db.valid_connection?(conn).should be_false
end
end

0 comments on commit 3b74892

Please sign in to comment.
Something went wrong with that request. Please try again.