# frozen-string-literal: true module Sequel module Plugins # The pg_auto_constraint_validations plugin automatically converts some constraint # violation exceptions that are raised by INSERT/UPDATE queries into validation # failures. This can allow for using the same error handling code for both # regular validation errors (checked before attempting the INSERT/UPDATE), and # constraint violations (raised during the INSERT/UPDATE). # # This handles the following constraint violations: # # * NOT NULL # * CHECK # * UNIQUE (except expression/functional indexes) # * FOREIGN KEY (both referencing and referenced by) # # If the plugin cannot convert the constraint violation error to a validation # error, it just reraises the initial exception, so this should not cause # problems if the plugin doesn't know how to convert the exception. # # This plugin is not intended as a replacement for other validations, # it is intended as a last resort. The purpose of validations is to provide nice # error messages for the user, and the error messages generated by this plugin are # fairly generic by default. The error messages can be customized per constraint type # using the :messages plugin option, and individually per constraint using # +pg_auto_constraint_validation_override+ (see below). # # This plugin only works on the postgres adapter when using the pg 0.16+ driver, # PostgreSQL 9.3+ server, and PostgreSQL 9.3+ client library (libpq). In other cases # it will be a no-op. # # Example: # # album = Album.new(:artist_id=>1) # Assume no such artist exists # begin # album.save # rescue Sequel::ValidationFailed # album.errors.on(:artist_id) # ['is invalid'] # end # # While the database usually provides enough information to correctly associated # constraint violations with model columns, there are cases where it does not. # In those cases, you can override the handling of specific constraint violations # to be associated to particular column(s), and use a specific error message: # # Album.pg_auto_constraint_validation_override(:constraint_name, [:column1], "validation error message") # # Using the pg_auto_constraint_validations plugin requires 5 queries per # model at load time in order to gather the necessary metadata. For applications # with a large number of models, this can result in a noticeable delay during model # initialization. To mitigate this issue, you can cache the necessary metadata in # a file with the :cache_file option: # # Sequel::Model.plugin :pg_auto_constraint_validations, cache_file: 'db/pgacv.cache' # # The file does not have to exist when loading the plugin. If it exists, the plugin # will load the cache and use the cached results instead of issuing queries if there # is an entry in the cache. If there is no entry in the cache, it will update the # in-memory cache with the metadata results. To save the in in-memory cache back to # the cache file, run: # # Sequel::Model.dump_pg_auto_constraint_validations_cache # # Note that when using the :cache_file option, it is up to the application to ensure # that the dumped cached metadata reflects the current state of the database. Sequel # does no checking to ensure this, as checking would take time and the # purpose of this code is to take a shortcut. # # The cached schema is dumped in Marshal format, since it is the fastest # and it handles all ruby objects used in the metadata. Because of this, # you should not attempt to load the metadata from a untrusted file. # # Usage: # # # Make all model subclasses automatically convert constraint violations # # to validation failures (called before loading subclasses) # Sequel::Model.plugin :pg_auto_constraint_validations # # # Make the Album class automatically convert constraint violations # # to validation failures # Album.plugin :pg_auto_constraint_validations module PgAutoConstraintValidations ( # The default error messages for each constraint violation type. DEFAULT_ERROR_MESSAGES = { :not_null=>"is not present", :check=>"is invalid", :unique=>'is already taken', :foreign_key=>'is invalid', :referenced_by=>'cannot be changed currently' }.freeze).each_value(&:freeze) # Setup the constraint violation metadata. Options: # :cache_file :: File storing cached metadata, to avoid queries for each model # :messages :: Override the default error messages for each constraint # violation type (:not_null, :check, :unique, :foreign_key, :referenced_by) def self.configure(model, opts=OPTS) model.instance_exec do if @pg_auto_constraint_validations_cache_file = opts[:cache_file] @pg_auto_constraint_validations_cache = if ::File.file?(@pg_auto_constraint_validations_cache_file) cache = Marshal.load(File.read(@pg_auto_constraint_validations_cache_file)) cache.each_value do |hash| hash.freeze.each_value(&:freeze) end else {} end else @pg_auto_constraint_validations_cache = nil end setup_pg_auto_constraint_validations @pg_auto_constraint_validations_messages = (@pg_auto_constraint_validations_messages || DEFAULT_ERROR_MESSAGES).merge(opts[:messages] || OPTS).freeze end nil end module ClassMethods # Hash of metadata checked when an instance attempts to convert a constraint # violation into a validation failure. attr_reader :pg_auto_constraint_validations # Hash of error messages keyed by constraint type symbol to use in the # generated validation failures. attr_reader :pg_auto_constraint_validations_messages Plugins.inherited_instance_variables(self, :@pg_auto_constraint_validations=>nil, :@pg_auto_constraint_validations_messages=>nil, :@pg_auto_constraint_validations_cache=>nil, :@pg_auto_constraint_validations_cache_file=>nil) Plugins.after_set_dataset(self, :setup_pg_auto_constraint_validations) # Dump the in-memory cached metadata to the cache file. def dump_pg_auto_constraint_validations_cache raise Error, "No pg_auto_constraint_validations setup" unless file = @pg_auto_constraint_validations_cache_file File.open(file, 'wb'){|f| f.write(Marshal.dump(@pg_auto_constraint_validations_cache))} nil end # Override the constraint validation columns and message for a given constraint def pg_auto_constraint_validation_override(constraint, columns, message) pgacv = Hash[@pg_auto_constraint_validations] overrides = pgacv[:overrides] = Hash[pgacv[:overrides]] overrides[constraint] = [Array(columns), message].freeze overrides.freeze @pg_auto_constraint_validations = pgacv.freeze nil end private # Get the list of constraints, unique indexes, foreign keys in the current # table, and keys in the current table referenced by foreign keys in other # tables. Store this information so that if a constraint violation occurs, # all necessary metadata is already available in the model, so a query is # not required at runtime. This is both for performance and because in # general after the constraint violation failure you will be inside a # failed transaction and not able to execute queries. def setup_pg_auto_constraint_validations return unless @dataset case @dataset.first_source_table when Symbol, String, SQL::Identifier, SQL::QualifiedIdentifier convert_errors = db.respond_to?(:error_info) end unless convert_errors # Might be a table returning function or subquery, skip handling those. # Might have db not support error_info, skip handling that. @pg_auto_constraint_validations = nil return end cache = @pg_auto_constraint_validations_cache literal_table_name = dataset.literal(table_name) unless cache && (metadata = cache[literal_table_name]) checks = {} indexes = {} foreign_keys = {} referenced_by = {} db.check_constraints(table_name).each do |k, v| checks[k] = v[:columns].dup.freeze unless v[:columns].empty? end db.indexes(table_name, :include_partial=>true).each do |k, v| if v[:unique] indexes[k] = v[:columns].dup.freeze end end db.foreign_key_list(table_name, :schema=>false).each do |fk| foreign_keys[fk[:name]] = fk[:columns].dup.freeze end db.foreign_key_list(table_name, :reverse=>true, :schema=>false).each do |fk| referenced_by[[fk[:schema], fk[:table], fk[:name]].freeze] = fk[:key].dup.freeze end schema, table = db[:pg_class]. join(:pg_namespace, :oid=>:relnamespace, db.send(:regclass_oid, table_name)=>:oid). get([:nspname, :relname]) metadata = { :schema=>schema, :table=>table, :check=>checks, :unique=>indexes, :foreign_key=>foreign_keys, :referenced_by=>referenced_by, :overrides=>OPTS }.freeze metadata.each_value(&:freeze) if cache cache[literal_table_name] = metadata end end @pg_auto_constraint_validations = metadata nil end end module InstanceMethods private # Yield to the given block, and if a Sequel::ConstraintViolation is raised, try # to convert it to a Sequel::ValidationFailed error using the PostgreSQL error # metadata. def check_pg_constraint_error(ds) yield rescue Sequel::ConstraintViolation => e begin unless cv_info = model.pg_auto_constraint_validations # Necessary metadata does not exist, just reraise the exception. raise e end info = ds.db.error_info(e) m = ds.method(:output_identifier) schema = info[:schema] table = info[:table] if constraint = info[:constraint] constraint = m.call(constraint) columns, message = cv_info[:overrides][constraint] if columns override = true add_pg_constraint_validation_error(columns, message) end end messages = model.pg_auto_constraint_validations_messages unless override case e when Sequel::NotNullConstraintViolation if column = info[:column] add_pg_constraint_validation_error([m.call(column)], messages[:not_null]) end when Sequel::CheckConstraintViolation if columns = cv_info[:check][constraint] add_pg_constraint_validation_error(columns, messages[:check]) end when Sequel::UniqueConstraintViolation if columns = cv_info[:unique][constraint] add_pg_constraint_validation_error(columns, messages[:unique]) end when Sequel::ForeignKeyConstraintViolation message_primary = info[:message_primary] if message_primary.start_with?('update') # This constraint violation is different from the others, because the constraint # referenced is a constraint for a different table, not for this table. This # happens when another table references the current table, and the referenced # column in the current update is modified such that referential integrity # would be broken. Use the reverse foreign key information to figure out # which column is affected in that case. skip_schema_table_check = true if columns = cv_info[:referenced_by][[m.call(schema), m.call(table), constraint]] add_pg_constraint_validation_error(columns, messages[:referenced_by]) end elsif message_primary.start_with?('insert') if columns = cv_info[:foreign_key][constraint] add_pg_constraint_validation_error(columns, messages[:foreign_key]) end end end end rescue # If there is an error trying to conver the constraint violation # into a validation failure, it's best to just raise the constraint # violation. This can make debugging the above block of code more # difficult. raise e else unless skip_schema_table_check # The constraint violation could be caused by a trigger modifying # a different table. Check that the error schema and table # match the model's schema and table, or clear the validation error # that was set above. if schema != cv_info[:schema] || table != cv_info[:table] errors.clear end end if errors.empty? # If we weren't able to parse the constraint violation metadata and # convert it to an appropriate validation failure, or the schema/table # didn't match, then raise the constraint violation. raise e end # Integrate with error_splitter plugin to split any multi-column errors # and add them as separate single column errors if respond_to?(:split_validation_errors, true) split_validation_errors(errors) end vf = ValidationFailed.new(self) vf.set_backtrace(e.backtrace) vf.wrapped_exception = e raise vf end end # If there is a single column instead of an array of columns, add the error # for the column, otherwise add the error for the array of columns. def add_pg_constraint_validation_error(column, message) column = column.first if column.length == 1 errors.add(column, message) end # Convert PostgreSQL constraint errors when inserting. def _insert_raw(ds) check_pg_constraint_error(ds){super} end # Convert PostgreSQL constraint errors when inserting. def _insert_select_raw(ds) check_pg_constraint_error(ds){super} end # Convert PostgreSQL constraint errors when updating. def _update_without_checking(_) check_pg_constraint_error(_update_dataset){super} end end end end end