File: pg_auto_constraint_validations.rb

package info (click to toggle)
ruby-sequel 5.63.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 10,408 kB
  • sloc: ruby: 113,747; makefile: 3
file content (350 lines) | stat: -rw-r--r-- 15,477 bytes parent folder | download | duplicates (2)
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