Skip to content

Instantly share code, notes, and snippets.

@serihiro
Last active June 25, 2022 16:06
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save serihiro/3b621faf1b8203d69822 to your computer and use it in GitHub Desktop.
Save serihiro/3b621faf1b8203d69822 to your computer and use it in GitHub Desktop.
RecordNotUniqueからカラム情報とか取れないか知りたかったのでrailsのソースを雑に追ってみた

結論

  • SQLiteとMySQLは文字列のエラーメッセージしか持ってないので無理そう
  • PostgreSQLはいけるかもしれないが未確認

ソースリーディング対象branch

  • rails4.2-stable

定義箇所

https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/errors.rb#L98

 # Raised when a record cannot be inserted because it would violate a uniqueness constraint.
  class RecordNotUnique < WrappedDatabaseException
  end

reading log

WrappedDatabaseExceptionとは

  # Defunct wrapper class kept for compatibility.
  # +StatementInvalid+ wraps the original exception now.
  class WrappedDatabaseException < StatementInvalid
  end

StatementInvalidとは

  # Superclass for all database execution errors.
  #
  # Wraps the underlying database error as +original_exception+.
  class StatementInvalid < ActiveRecordError
    attr_reader :original_exception

    def initialize(message, original_exception = nil)
      super(message)
      @original_exception = original_exception
    end
  end

ActiveRecordErrorとは

  # = Active Record Errors
  #
  # Generic Active Record exception class.
  class ActiveRecordError < StandardError
  end
  • まとめると以下の継承関係になっている
StandardError <- ActiveRecordError <- StatementInvalid <- WrappedDatabaseException <- RecordNotUnique

どこでRecordNotUniqueがraiseされるのか?

MySQLの場合

      def translate_exception(exception, message)
        case error_number(exception)
        when 1062
          RecordNotUnique.new(message, exception)
        when 1452
          InvalidForeignKey.new(message, exception)
        else
          super
        end
      end
  • うまくやればエラーを起こしたカラム情報まで取れるかも知れないが、実際に追ってみないと分からない。StatementInvalidoriginal_exceptionに入ってるerrorにここでrescueしたerrorが入ってるはず。

PostgreSQLの場合

        # See http://www.postgresql.org/docs/9.1/static/errcodes-appendix.html
        FOREIGN_KEY_VIOLATION = "23503"
        UNIQUE_VIOLATION      = "23505"

        def translate_exception(exception, message)
          return exception unless exception.respond_to?(:result)

          case exception.result.try(:error_field, PGresult::PG_DIAG_SQLSTATE)
          when UNIQUE_VIOLATION
            RecordNotUnique.new(message, exception)
          when FOREIGN_KEY_VIOLATION
            InvalidForeignKey.new(message, exception)
          else
            super
          end
        end
  • mysqlとほぼ同じような感じだがexception.result.error_fieldというメソッドを呼んでいるのでカラムが特定できるかも知れない
  • あとstring定数freezeしなきゃ案件だ。

SQLiteの場合

        def translate_exception(exception, message)
          case exception.message
          # SQLite 3.8.2 returns a newly formatted error message:
          #   UNIQUE constraint failed: *table_name*.*column_name*
          # Older versions of SQLite return:
          #   column *column_name* is not unique
          when /column(s)? .* (is|are) not unique/, /UNIQUE constraint failed: .*/
            RecordNotUnique.new(message, exception)
          else
            super
          end
        end
  • なかなか泥臭いことをしておる。

ところでtranslate_exception(exception, message)はどこで呼ばれるのか

      def translate_exception_class(e, sql)
        begin
          message = "#{e.class.name}: #{e.message}: #{sql}"
        rescue Encoding::CompatibilityError
          message = "#{e.class.name}: #{e.message.force_encoding sql.encoding}: #{sql}"
        end

        exception = translate_exception(e, message)
        exception.set_backtrace e.backtrace
        exception
      end

      def log(sql, name = "SQL", binds = [], statement_name = nil)
        @instrumenter.instrument(
          "sql.active_record",
          :sql            => sql,
          :name           => name,
          :connection_id  => object_id,
          :statement_name => statement_name,
          :binds          => binds) { yield }
      rescue => e
        raise translate_exception_class(e, sql)
      end

      def translate_exception(exception, message)
        # override in derived class
        ActiveRecord::StatementInvalid.new(message, exception)
      end

何が入るのか

雑にrails appを書いて実際にActiveRecord::RecordNotUniqueをraiseさせてみる

class TodosController < ApplicationController
  def index
    render json: (Todo.all).to_json
  end

  def create
    Todo.create!(content: params[:content])
    render json: {}
  rescue ActiveRecord::RecordNotUnique => e
    puts e.class
    puts e.cause.class
    puts e.cause.message
  end
end
 CREATE TABLE `todos` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `content` varchar(255) DEFAULT NULL,
  `string` varchar(255) DEFAULT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `index_todos_on_content` (`content`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 

SQLite

ActiveRecord::RecordNotUnique
SQLite3::ConstraintException
UNIQUE constraint failed: todos.content

MySQL

ActiveRecord::RecordNotUnique
Mysql2::Error
Duplicate entry 'test' for key 'index_todos_on_content'

ところで Mysql2::Errorはこれ https://github.com/brianmario/mysql2/blob/1d4de8963c71ce88772a0e27883066160c3dbbd4/lib/mysql2/error.rb

module Mysql2
  class Error < StandardError
    ENCODE_OPTS = {
      :undef => :replace,
      :invalid => :replace,
      :replace => '?'.freeze,
    }.freeze

    attr_reader :error_number, :sql_state

    # Mysql gem compatibility
    alias_method :errno, :error_number
    alias_method :error, :message
    
    ...
    
  end
end    

やはりエラーになったカラム情報はもってないようだ。文字列パースするぐらいしかないかな。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment