File: pg_auto_constraint_validations.rb

package info (click to toggle)
ruby-sequel 5.15.0-1
  • links: PTS, VCS
  • area: main
  • in suites: buster
  • size: 8,856 kB
  • sloc: ruby: 95,762; makefile: 3
file content (260 lines) | stat: -rw-r--r-- 11,087 bytes parent folder | download
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
# 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.  The error messages can be customized using the :messages plugin
    # option, but there is only a single message used per constraint type.
    #
    # 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
    # 
    # 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:
      # :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
          setup_pg_auto_constraint_validations
          @pg_auto_constraint_validations_messages = (@pg_auto_constraint_validations_messages || DEFAULT_ERROR_MESSAGES).merge(opts[:messages] || OPTS).freeze
        end
      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)
        Plugins.after_set_dataset(self, :setup_pg_auto_constraint_validations)

        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

          checks = {}
          indexes = {}
          foreign_keys = {}
          referenced_by = {}

          db.check_constraints(table_name).each do |k, v|
            checks[k] = v[:columns].dup.freeze
          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])

          (@pg_auto_constraint_validations = {
            :schema=>schema,
            :table=>table,
            :check=>checks,
            :unique=>indexes,
            :foreign_key=>foreign_keys,
            :referenced_by=>referenced_by
          }.freeze).each_value(&:freeze)
        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)
            end
            messages = model.pg_auto_constraint_validations_messages

            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
          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