# 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

