1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
|
# 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
# :nocov:
case e
# :nocov:
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
|